Skip to content

Commit ad96125

Browse files
lukaszlenartclaude
andcommitted
feat: Implement Claude API native support - Phase 1 Core Foundation
Add native Claude API support per issue #390, implementing Phase 1 of the implementation plan from PR #408. Core Implementation: - New `claude/` module with Scala 3 priority (3.3.6 + 2.13.16 cross-compilation) - ClaudeClient trait and ClaudeSyncClient for API operations - Messages API (/v1/messages) and Models API (/v1/models) support - Proper authentication with x-api-key and anthropic-version headers Claude-Specific Features: - ContentBlock architecture for messages (vs simple strings) - System parameter support (vs role-based system messages) - Image support via ContentBlock with base64 encoding - Tool calling foundation models (Tool, ToolInputSchema, PropertySchema) Models & Configuration: - ClaudeConfig with environment variable support - Claude model enums (Claude 3.5 Sonnet, Haiku, Opus, etc.) - Comprehensive error handling with ClaudeException hierarchy - JSON serialization using uPickle with proper ReadWriter instances Testing: - Unit tests for models, config, and core functionality - All tests passing (ContentBlockSpec, ClaudeModelSpec, MessageSpec) - Code formatted with sbt scalafmt as per project requirements This foundation enables future phases including streaming support (Phase 2) and advanced features (Phase 3+). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent a765937 commit ad96125

19 files changed

+1036
-0
lines changed

build.sbt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ lazy val root = (project in file("."))
1818
.aggregate(allAgregates: _*)
1919

2020
lazy val allAgregates = core.projectRefs ++
21+
claude.projectRefs ++
2122
fs2.projectRefs ++
2223
zio.projectRefs ++
2324
pekko.projectRefs ++
@@ -39,6 +40,19 @@ lazy val core = (projectMatrix in file("core"))
3940
)
4041
.settings(commonSettings: _*)
4142

43+
lazy val claude = (projectMatrix in file("claude"))
44+
.jvmPlatform(
45+
scalaVersions = scala3 ++ scala2 // Scala 3 first priority
46+
)
47+
.settings(commonSettings: _*)
48+
.settings(
49+
libraryDependencies ++= Seq(
50+
Libraries.tapirApispecDocs,
51+
Libraries.uJsonCirce,
52+
Libraries.uPickle
53+
) ++ Libraries.sttpApispec ++ Libraries.sttpClient ++ Seq(Libraries.scalaTest)
54+
)
55+
4256
lazy val fs2 = (projectMatrix in file("streaming/fs2"))
4357
.jvmPlatform(
4458
scalaVersions = scala2 ++ scala3

claude/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Claude API Module
2+
3+
This module provides native support for Anthropic's Claude API within the sttp-openai library.
4+
5+
## Features
6+
7+
- Native Claude API support (not OpenAI compatibility layer)
8+
- Support for Claude's ContentBlock structure for messages
9+
- Authentication via x-api-key and anthropic-version headers
10+
- Messages API implementation
11+
- Models API implementation
12+
- Error handling with Claude-specific exceptions
13+
14+
## Usage
15+
16+
```scala
17+
import sttp.ai.claude._
18+
import sttp.client4._
19+
20+
val config = ClaudeConfig(
21+
apiKey = "your-api-key",
22+
anthropicVersion = "2023-06-01"
23+
)
24+
25+
val client = ClaudeClient(config, DefaultSyncBackend())
26+
```
27+
28+
## API Endpoints Supported
29+
30+
- Messages API (`/v1/messages`)
31+
- Models API (`/v1/models`)
32+
33+
## Key Differences from OpenAI API
34+
35+
- Uses `ContentBlock` arrays instead of simple strings for message content
36+
- System messages handled via `system` parameter, not role
37+
- Different authentication headers (`x-api-key`, `anthropic-version`)
38+
- Different tool calling structure
39+
- Image support via ContentBlock with base64 encoding
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package sttp.ai.claude
2+
3+
import sttp.ai.claude.ClaudeExceptions.ClaudeException
4+
import sttp.ai.claude.config.ClaudeConfig
5+
import sttp.ai.claude.requests.MessageRequest
6+
import sttp.ai.claude.responses.{MessageResponse, ModelsResponse}
7+
import sttp.client4._
8+
import sttp.model.Uri
9+
import upickle.default._
10+
11+
trait ClaudeClient {
12+
def createMessage(request: MessageRequest): Request[Either[ClaudeException, MessageResponse]]
13+
def listModels(): Request[Either[ClaudeException, ModelsResponse]]
14+
}
15+
16+
class ClaudeClientImpl(config: ClaudeConfig) extends ClaudeClient {
17+
18+
private val claudeUris = new ClaudeUris(config.baseUrl)
19+
20+
private def claudeAuthRequest =
21+
basicRequest
22+
.header("x-api-key", config.apiKey)
23+
.header("anthropic-version", config.anthropicVersion)
24+
.header("content-type", "application/json")
25+
26+
private def asJson_parseErrors[T: Reader]: ResponseAs[Either[ClaudeException, T]] =
27+
asString.mapWithMetadata { (responseBody, metadata) =>
28+
responseBody match {
29+
case Left(error) =>
30+
Left(
31+
ClaudeException.DeserializationClaudeException(
32+
new Exception(error),
33+
metadata
34+
)
35+
)
36+
case Right(body) =>
37+
try
38+
Right(read[T](body))
39+
catch {
40+
case e: Exception =>
41+
try {
42+
val errorResponse = read[sttp.ai.claude.responses.ErrorResponse](body)
43+
Left(mapErrorToException(errorResponse, metadata))
44+
} catch {
45+
case _: Exception =>
46+
Left(ClaudeException.DeserializationClaudeException(e, metadata))
47+
}
48+
}
49+
}
50+
}
51+
52+
private def mapErrorToException(
53+
errorResponse: sttp.ai.claude.responses.ErrorResponse,
54+
metadata: sttp.model.ResponseMetadata
55+
): ClaudeException = {
56+
val error = errorResponse.error
57+
val cause = ResponseException.UnexpectedStatusCode(error.message, metadata)
58+
59+
error.`type` match {
60+
case "authentication_error" =>
61+
new ClaudeException.AuthenticationException(
62+
Some(error.message),
63+
Some(error.`type`),
64+
None,
65+
None,
66+
cause
67+
)
68+
case "permission_error" =>
69+
new ClaudeException.PermissionException(
70+
Some(error.message),
71+
Some(error.`type`),
72+
None,
73+
None,
74+
cause
75+
)
76+
case "rate_limit_error" =>
77+
new ClaudeException.RateLimitException(
78+
Some(error.message),
79+
Some(error.`type`),
80+
None,
81+
None,
82+
cause
83+
)
84+
case "invalid_request_error" =>
85+
new ClaudeException.InvalidRequestException(
86+
Some(error.message),
87+
Some(error.`type`),
88+
None,
89+
None,
90+
cause
91+
)
92+
case _ =>
93+
new ClaudeException.APIException(
94+
Some(error.message),
95+
Some(error.`type`),
96+
None,
97+
None,
98+
cause
99+
)
100+
}
101+
}
102+
103+
override def createMessage(request: MessageRequest): Request[Either[ClaudeException, MessageResponse]] =
104+
claudeAuthRequest
105+
.post(claudeUris.Messages)
106+
.body(write(request))
107+
.response(asJson_parseErrors[MessageResponse])
108+
109+
override def listModels(): Request[Either[ClaudeException, ModelsResponse]] =
110+
claudeAuthRequest
111+
.get(claudeUris.Models)
112+
.response(asJson_parseErrors[ModelsResponse])
113+
}
114+
115+
class ClaudeUris(baseUri: Uri) {
116+
val Messages: Uri = baseUri.addPath("v1", "messages")
117+
val Models: Uri = baseUri.addPath("v1", "models")
118+
}
119+
120+
object ClaudeClient {
121+
def apply(config: ClaudeConfig): ClaudeClientImpl = new ClaudeClientImpl(config)
122+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package sttp.ai.claude
2+
3+
import sttp.client4.ResponseException
4+
import sttp.client4.ResponseException.{DeserializationException, UnexpectedStatusCode}
5+
import sttp.model.ResponseMetadata
6+
7+
object ClaudeExceptions {
8+
sealed abstract class ClaudeException(
9+
val message: Option[String],
10+
val `type`: Option[String],
11+
val param: Option[String],
12+
val code: Option[String],
13+
val cause: ResponseException[String]
14+
) extends Exception(cause.getMessage, cause)
15+
16+
object ClaudeException {
17+
class DeserializationClaudeException(
18+
message: String,
19+
cause: DeserializationException
20+
) extends ClaudeException(Some(message), None, None, None, cause)
21+
22+
object DeserializationClaudeException {
23+
def apply(cause: DeserializationException): DeserializationClaudeException =
24+
new DeserializationClaudeException(cause.getMessage, cause)
25+
26+
def apply(cause: Exception, meta: ResponseMetadata): DeserializationClaudeException = apply(
27+
DeserializationException(cause.getMessage, cause, meta)
28+
)
29+
}
30+
31+
class RateLimitException(
32+
message: Option[String],
33+
`type`: Option[String],
34+
param: Option[String],
35+
code: Option[String],
36+
cause: UnexpectedStatusCode[String]
37+
) extends ClaudeException(message, `type`, param, code, cause)
38+
39+
class InvalidRequestException(
40+
message: Option[String],
41+
`type`: Option[String],
42+
param: Option[String],
43+
code: Option[String],
44+
cause: UnexpectedStatusCode[String]
45+
) extends ClaudeException(message, `type`, param, code, cause)
46+
47+
class AuthenticationException(
48+
message: Option[String],
49+
`type`: Option[String],
50+
param: Option[String],
51+
code: Option[String],
52+
cause: UnexpectedStatusCode[String]
53+
) extends ClaudeException(message, `type`, param, code, cause)
54+
55+
class PermissionException(
56+
message: Option[String],
57+
`type`: Option[String],
58+
param: Option[String],
59+
code: Option[String],
60+
cause: UnexpectedStatusCode[String]
61+
) extends ClaudeException(message, `type`, param, code, cause)
62+
63+
class TryAgain(
64+
message: Option[String],
65+
`type`: Option[String],
66+
param: Option[String],
67+
code: Option[String],
68+
cause: UnexpectedStatusCode[String]
69+
) extends ClaudeException(message, `type`, param, code, cause)
70+
71+
class ServiceUnavailableException(
72+
message: Option[String],
73+
`type`: Option[String],
74+
param: Option[String],
75+
code: Option[String],
76+
cause: UnexpectedStatusCode[String]
77+
) extends ClaudeException(message, `type`, param, code, cause)
78+
79+
class APIException(
80+
message: Option[String],
81+
`type`: Option[String],
82+
param: Option[String],
83+
code: Option[String],
84+
cause: UnexpectedStatusCode[String]
85+
) extends ClaudeException(message, `type`, param, code, cause)
86+
}
87+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package sttp.ai.claude
2+
3+
import sttp.ai.claude.config.ClaudeConfig
4+
import sttp.ai.claude.requests.MessageRequest
5+
import sttp.ai.claude.responses.{MessageResponse, ModelsResponse}
6+
import sttp.client4.{DefaultSyncBackend, SyncBackend}
7+
8+
class ClaudeSyncClient(config: ClaudeConfig, backend: SyncBackend = DefaultSyncBackend()) {
9+
private val client = new ClaudeClientImpl(config)
10+
11+
def createMessage(request: MessageRequest): MessageResponse =
12+
client.createMessage(request).send(backend).body match {
13+
case Left(exception) => throw exception
14+
case Right(response) => response
15+
}
16+
17+
def listModels(): ModelsResponse =
18+
client.listModels().send(backend).body match {
19+
case Left(exception) => throw exception
20+
case Right(response) => response
21+
}
22+
}
23+
24+
object ClaudeSyncClient {
25+
def apply(config: ClaudeConfig): ClaudeSyncClient = new ClaudeSyncClient(config)
26+
def apply(config: ClaudeConfig, backend: SyncBackend): ClaudeSyncClient = new ClaudeSyncClient(config, backend)
27+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package sttp.ai.claude.config
2+
3+
import sttp.model.Uri
4+
5+
import scala.concurrent.duration.{Duration, DurationInt}
6+
7+
case class ClaudeConfig(
8+
apiKey: String,
9+
anthropicVersion: String = "2023-06-01",
10+
baseUrl: Uri = ClaudeConfig.DefaultBaseUrl,
11+
timeout: Duration = 60.seconds,
12+
maxRetries: Int = 3,
13+
organization: Option[String] = None
14+
)
15+
16+
object ClaudeConfig {
17+
val DefaultBaseUrl: Uri = Uri.unsafeParse("https://api.anthropic.com")
18+
19+
def fromEnv: ClaudeConfig = {
20+
val apiKey =
21+
sys.env.getOrElse("ANTHROPIC_API_KEY", throw new IllegalArgumentException("ANTHROPIC_API_KEY environment variable is required"))
22+
val anthropicVersion = sys.env.getOrElse("ANTHROPIC_VERSION", "2023-06-01")
23+
val baseUrl = sys.env.get("ANTHROPIC_BASE_URL").map(Uri.unsafeParse).getOrElse(DefaultBaseUrl)
24+
25+
ClaudeConfig(
26+
apiKey = apiKey,
27+
anthropicVersion = anthropicVersion,
28+
baseUrl = baseUrl
29+
)
30+
}
31+
32+
def apply(apiKey: String): ClaudeConfig = ClaudeConfig(
33+
apiKey = apiKey
34+
)
35+
36+
def apply(apiKey: String, anthropicVersion: String): ClaudeConfig = ClaudeConfig(
37+
apiKey = apiKey,
38+
anthropicVersion = anthropicVersion
39+
)
40+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package sttp.ai.claude.models
2+
3+
import upickle.default.{macroRW, ReadWriter}
4+
5+
sealed abstract class ClaudeModel(val value: String) {
6+
override def toString: String = value
7+
}
8+
9+
object ClaudeModel {
10+
case object Claude3_5Sonnet extends ClaudeModel("claude-3-5-sonnet-20241022")
11+
case object Claude3_5SonnetLatest extends ClaudeModel("claude-3-5-sonnet-latest")
12+
case object Claude3_5Haiku extends ClaudeModel("claude-3-5-haiku-20241022")
13+
case object Claude3_5HaikuLatest extends ClaudeModel("claude-3-5-haiku-latest")
14+
case object Claude3Opus extends ClaudeModel("claude-3-opus-20240229")
15+
case object Claude3Sonnet extends ClaudeModel("claude-3-sonnet-20240229")
16+
case object Claude3Haiku extends ClaudeModel("claude-3-haiku-20240307")
17+
18+
val values: Set[ClaudeModel] = Set(
19+
Claude3_5Sonnet,
20+
Claude3_5SonnetLatest,
21+
Claude3_5Haiku,
22+
Claude3_5HaikuLatest,
23+
Claude3Opus,
24+
Claude3Sonnet,
25+
Claude3Haiku
26+
)
27+
28+
def fromString(value: String): Option[ClaudeModel] = values.find(_.value == value)
29+
30+
implicit val rw: ReadWriter[ClaudeModel] = ReadWriter.merge(
31+
macroRW[Claude3_5Sonnet.type],
32+
macroRW[Claude3_5SonnetLatest.type],
33+
macroRW[Claude3_5Haiku.type],
34+
macroRW[Claude3_5HaikuLatest.type],
35+
macroRW[Claude3Opus.type],
36+
macroRW[Claude3Sonnet.type],
37+
macroRW[Claude3Haiku.type]
38+
)
39+
}

0 commit comments

Comments
 (0)