Skip to content

Commit 3ff931e

Browse files
committed
Add response format validation and Anthropic fixes
1 parent fcb3ebd commit 3ff931e

7 files changed

Lines changed: 205 additions & 85 deletions

File tree

client/anthropic.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,23 @@ func audioFormatToMediaType(format string) string {
426426
// EnableCaching, tools, thinking, metadata, serviceTier,
427427
// outputConfig — was previously re-applied in both methods.
428428
func (c *AnthropicClient) buildAnthropicRequest(ctx context.Context, messages []EyrieMessage, opts ChatOptions, stream bool) (*http.Request, []byte, error) {
429+
if opts.ResponseFormat != nil {
430+
switch opts.ResponseFormat.Type {
431+
case "json_schema":
432+
if opts.ResponseFormat.Schema == "" {
433+
return nil, nil, fmt.Errorf("eyrie: anthropic json_schema response format requires a schema")
434+
}
435+
var schema map[string]interface{}
436+
if err := json.Unmarshal([]byte(opts.ResponseFormat.Schema), &schema); err != nil {
437+
return nil, nil, fmt.Errorf("eyrie: invalid anthropic response schema: %w", err)
438+
}
439+
opts.OutputSchema = opts.ResponseFormat.Schema
440+
case "json_object":
441+
return nil, nil, fmt.Errorf("eyrie: anthropic does not support json_object without a schema; use json_schema")
442+
default:
443+
return nil, nil, fmt.Errorf("eyrie: unsupported anthropic response format %q", opts.ResponseFormat.Type)
444+
}
445+
}
429446
messages = SanitizeMessages(messages)
430447
if opts.Model == "" {
431448
return nil, nil, fmt.Errorf("eyrie: model is required for anthropic")
@@ -501,11 +518,9 @@ func (c *AnthropicClient) buildAnthropicRequest(ctx context.Context, messages []
501518
return req, body, nil
502519
}
503520

504-
// NOTE: Anthropic does not support a native JSON mode (response_format).
505-
// Structured output with Anthropic is achieved via the tool-use pattern
506-
// (defining a tool whose input_schema is your desired output schema).
507-
// This is not implemented here; opts.ResponseFormat is ignored for Anthropic.
508-
// Future work: implement tool-use-based structured output for Anthropic.
521+
// Anthropic structured output is sent through output_config.format. A
522+
// schema-less json_object request is rejected instead of being silently
523+
// ignored because Anthropic requires a JSON schema.
509524
func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) {
510525
req, body, err := c.buildAnthropicRequest(ctx, messages, opts, false)
511526
if err != nil {

client/anthropic_features_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,33 @@ func TestResolveOutputConfig(t *testing.T) {
572572
}
573573
}
574574

575+
func TestAnthropicResponseFormatJSONSchema(t *testing.T) {
576+
c := NewAnthropicClient("test", "http://example.test")
577+
_, body, err := c.buildAnthropicRequest(context.Background(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{
578+
Model: "claude-test",
579+
ResponseFormat: &ResponseFormat{
580+
Type: "json_schema",
581+
Schema: `{"type":"object","properties":{"answer":{"type":"string"}}}`,
582+
},
583+
}, false)
584+
if err != nil {
585+
t.Fatal(err)
586+
}
587+
if !strings.Contains(string(body), `"output_config":{"format":{"type":"json_schema"`) {
588+
t.Fatalf("structured output was not wired into Anthropic request: %s", body)
589+
}
590+
}
591+
592+
func TestAnthropicResponseFormatRejectsSchemaLessJSON(t *testing.T) {
593+
c := NewAnthropicClient("test", "http://example.test")
594+
_, _, err := c.buildAnthropicRequest(context.Background(), nil, ChatOptions{
595+
ResponseFormat: &ResponseFormat{Type: "json_object"},
596+
}, false)
597+
if err == nil || !strings.Contains(err.Error(), "use json_schema") {
598+
t.Fatalf("expected actionable json_object error, got %v", err)
599+
}
600+
}
601+
575602
func TestAnthropicRequest_NewFields(t *testing.T) {
576603
t.Parallel()
577604
req := anthropicRequest{

go.mod

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

go.sum

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/grpc/README.md

Lines changed: 15 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,20 @@
1-
# internal/grpc — gRPC server skeleton (design note)
1+
# internal/grpc — optional gRPC server
22

3-
This package is a **skeleton**. eyrie does not currently depend on
4-
`google.golang.org/grpc` (verify with `go.mod`), and per repo policy we do
5-
**not** add that dependency speculatively. So this package ships:
3+
The transport is enabled with the `grpc` build tag. It uses Eyrie's small
4+
Go request/response structs with the registered `json` gRPC content subtype,
5+
which avoids generated protobuf code while retaining gRPC framing,
6+
interceptors, deadlines, status propagation, and HTTP/2 transport.
67

7-
- `grpc.go` — a dependency-free `ChatService` interface plus a no-op default
8-
implementation. This always compiles and lets the rest of the codebase
9-
reference the service contract today.
10-
- `server_grpc.go` — the real gRPC wiring, guarded by the `grpc` build tag so
11-
it is excluded from normal builds until the dependency and generated code
12-
exist. It is currently a documented stub (no grpc imports) describing exactly
13-
what to generate and wire.
8+
- `grpc.go` defines the transport-independent `ChatService` contract.
9+
- `server_grpc.go` registers and serves `eyrie.v1.ChatService/Chat`.
10+
- Clients must select `grpc.CallContentSubtype("json")`.
1411

15-
## What to generate when gRPC is adopted
12+
## Running
1613

17-
1. Add the dependencies:
14+
```sh
15+
go build -tags grpc ./...
16+
```
1817

19-
```sh
20-
go get google.golang.org/grpc google.golang.org/protobuf
21-
```
22-
23-
2. Define the service in `proto/eyrie/v1/chat.proto`:
24-
25-
```proto
26-
syntax = "proto3";
27-
package eyrie.v1;
28-
option go_package = "github.com/GrayCodeAI/eyrie/internal/grpc/eyriev1";
29-
30-
service ChatService {
31-
// Unary chat: one request, one response.
32-
rpc Chat(ChatRequest) returns (ChatResponse);
33-
}
34-
35-
message ChatRequest {
36-
string model = 1;
37-
string system_prompt = 2;
38-
string message = 3;
39-
int32 max_tokens = 4;
40-
}
41-
42-
message ChatResponse {
43-
string content = 1;
44-
string node_id = 2;
45-
string finish_reason = 3;
46-
}
47-
```
48-
49-
3. Generate stubs with `protoc` (or `buf`):
50-
51-
```sh
52-
protoc --go_out=. --go-grpc_out=. proto/eyrie/v1/chat.proto
53-
```
54-
55-
4. Implement the generated `ChatServiceServer` by adapting the existing
56-
`conversation.Engine` (see `internal/api/server.go` `handlePrompt` for the
57-
HTTP equivalent), then register it on a `grpc.Server` inside
58-
`server_grpc.go` (drop the stub body, remove the documentation comment).
59-
60-
5. Build/run with the tag: `go build -tags grpc ./...`.
61-
62-
Until then, importing this package yields the no-op `ChatService` in
63-
`grpc.go`, which keeps everything compiling with zero new dependencies.
18+
Callers provide a `ChatService` implementation to `Serve` or `NewServer`.
19+
The untagged build retains only the service contract, so consumers that do not
20+
need a network server do not link the gRPC runtime.

internal/grpc/server_grpc.go

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,77 @@
11
//go:build grpc
22

3-
// This file is the real gRPC server wiring. It is excluded from normal builds
4-
// via the "grpc" build tag because eyrie does not yet depend on
5-
// google.golang.org/grpc. It is intentionally a documented stub: it compiles
6-
// under `go build -tags grpc ./...` without pulling in grpc, and marks exactly
7-
// where the generated code and server registration belong.
8-
//
9-
// To activate (see README.md for full steps):
10-
// 1. go get google.golang.org/grpc google.golang.org/protobuf
11-
// 2. generate eyriev1 stubs from proto/eyrie/v1/chat.proto
12-
// 3. replace the body of Serve below with a real grpc.Server that registers
13-
// a ChatServiceServer adapting conversation.Engine, and drop this note.
14-
153
package grpc
164

175
import (
18-
"errors"
6+
"context"
7+
"encoding/json"
8+
"fmt"
199
"net"
10+
11+
googlegrpc "google.golang.org/grpc"
12+
"google.golang.org/grpc/encoding"
2013
)
2114

22-
// errGRPCNotWired signals that the gRPC dependency/codegen has not been added.
23-
var errGRPCNotWired = errors.New("eyrie/grpc: gRPC server not wired up (run codegen; see internal/grpc/README.md)")
15+
// JSONCodec is the wire codec used by Eyrie's dependency-light gRPC surface.
16+
// Clients select it with grpc.CallContentSubtype("json").
17+
type JSONCodec struct{}
18+
19+
func (JSONCodec) Name() string { return "json" }
20+
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
21+
return json.Marshal(v)
22+
}
23+
func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
24+
return json.Unmarshal(data, v)
25+
}
26+
27+
func init() {
28+
encoding.RegisterCodec(JSONCodec{})
29+
}
30+
31+
// NewServer creates a gRPC server and registers the supplied ChatService.
32+
func NewServer(svc ChatService, opts ...googlegrpc.ServerOption) (*googlegrpc.Server, error) {
33+
if svc == nil {
34+
return nil, fmt.Errorf("eyrie/grpc: chat service is required")
35+
}
36+
server := googlegrpc.NewServer(opts...)
37+
server.RegisterService(&chatServiceDesc, svc)
38+
return server, nil
39+
}
40+
41+
// Serve registers svc and serves requests until the listener is closed or the
42+
// server is stopped.
43+
func Serve(lis net.Listener, svc ChatService, opts ...googlegrpc.ServerOption) error {
44+
if lis == nil {
45+
return fmt.Errorf("eyrie/grpc: listener is required")
46+
}
47+
server, err := NewServer(svc, opts...)
48+
if err != nil {
49+
return err
50+
}
51+
return server.Serve(lis)
52+
}
53+
54+
func chatHandler(srv interface{}, ctx context.Context, decode func(interface{}) error, interceptor googlegrpc.UnaryServerInterceptor) (interface{}, error) {
55+
req := new(ChatRequest)
56+
if err := decode(req); err != nil {
57+
return nil, err
58+
}
59+
if interceptor == nil {
60+
return srv.(ChatService).Chat(ctx, req)
61+
}
62+
info := &googlegrpc.UnaryServerInfo{Server: srv, FullMethod: "/eyrie.v1.ChatService/Chat"}
63+
handler := func(ctx context.Context, request interface{}) (interface{}, error) {
64+
return srv.(ChatService).Chat(ctx, request.(*ChatRequest))
65+
}
66+
return interceptor(ctx, req, info, handler)
67+
}
2468

25-
// Serve will listen on lis and serve the ChatService over gRPC once the
26-
// dependency and generated stubs are in place. Today it is a stub.
27-
//
28-
// TODO(grpc): construct grpc.NewServer(), register the generated
29-
// ChatServiceServer backed by svc, and call grpcServer.Serve(lis).
30-
func Serve(_ net.Listener, _ ChatService) error {
31-
return errGRPCNotWired
69+
var chatServiceDesc = googlegrpc.ServiceDesc{
70+
ServiceName: "eyrie.v1.ChatService",
71+
HandlerType: (*ChatService)(nil),
72+
Methods: []googlegrpc.MethodDesc{{
73+
MethodName: "Chat",
74+
Handler: chatHandler,
75+
}},
76+
Metadata: "eyrie/v1/chat.json",
3277
}

internal/grpc/server_grpc_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//go:build grpc
2+
3+
package grpc
4+
5+
import (
6+
"context"
7+
"net"
8+
"testing"
9+
10+
googlegrpc "google.golang.org/grpc"
11+
"google.golang.org/grpc/credentials/insecure"
12+
"google.golang.org/grpc/test/bufconn"
13+
)
14+
15+
type testChatService struct{}
16+
17+
func (testChatService) Chat(_ context.Context, req *ChatRequest) (*ChatResponse, error) {
18+
return &ChatResponse{Content: "echo: " + req.Message, NodeID: "node-1", FinishReason: "stop"}, nil
19+
}
20+
21+
func TestServerChatRoundTrip(t *testing.T) {
22+
lis := bufconn.Listen(1024 * 1024)
23+
server, err := NewServer(testChatService{})
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
go func() {
28+
_ = server.Serve(lis)
29+
}()
30+
t.Cleanup(func() {
31+
server.Stop()
32+
_ = lis.Close()
33+
})
34+
35+
conn, err := googlegrpc.NewClient(
36+
"passthrough:///eyrie-test",
37+
googlegrpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }),
38+
googlegrpc.WithTransportCredentials(insecure.NewCredentials()),
39+
googlegrpc.WithDefaultCallOptions(googlegrpc.CallContentSubtype("json")),
40+
)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
t.Cleanup(func() { _ = conn.Close() })
45+
46+
var response ChatResponse
47+
if err := conn.Invoke(context.Background(), "/eyrie.v1.ChatService/Chat", &ChatRequest{Message: "hello"}, &response); err != nil {
48+
t.Fatal(err)
49+
}
50+
if response.Content != "echo: hello" || response.NodeID != "node-1" || response.FinishReason != "stop" {
51+
t.Fatalf("unexpected response: %+v", response)
52+
}
53+
}

0 commit comments

Comments
 (0)