diff --git a/docs/providers.md b/docs/providers.md index 1ac3f974..77ef96a8 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -356,6 +356,8 @@ provider := completions.New( | Cached token details | ✅ | | ListModels / Test / TestModel | ✅ | +`Request.ResponseFormat` maps onto `response_format`. `text` and `json_object` are sent as `{"type":"..."}`. `json_schema` wraps the schema the way Chat Completions expects, `{"type":"json_schema","json_schema":{"name":"response","schema":{...}}}`; `strict` is not set, matching the Responses provider's `text.format`. A `json_schema` format with a nil `JSONSchema` is sent as `json_object`. + ### Custom HTTP Client Use `WithHTTPClient` for custom timeouts, proxies, or TLS settings: @@ -637,6 +639,7 @@ The provider reuses the same OpenAI-compatible chat-completions mapping as Twili | Assistant reasoning | `reasoning_content` field | | Tool call | `tool_calls` | | Tool result | `{"role":"tool","tool_call_id":...}` | +| `ResponseFormat` | `response_format`, same shape as the OpenAI Completions provider (`json_schema` → `{"name":"response","schema":{...}}`) | ### Supported Features diff --git a/provider/github/copilot/copilot.go b/provider/github/copilot/copilot.go index 57bfb9d2..c40c371a 100644 --- a/provider/github/copilot/copilot.go +++ b/provider/github/copilot/copilot.go @@ -185,18 +185,36 @@ func (p *Provider) buildRequest(request *sdk.Request) (*chatRequest, error) { req.Tools = convertTools(request.Tools) req.ToolChoice = convertToolChoice(request.ToolChoice) } - if request.ResponseFormat != nil { - req.ResponseFormat = &chatRespFormat{ - Type: string(request.ResponseFormat.Type), - JSONSchema: request.ResponseFormat.JSONSchema, - } - } + req.ResponseFormat = convertResponseFormat(request.ResponseFormat) if err := sdk.ApplyProviderOptions(p.Name(), request.ProviderOptions, req); err != nil { return nil, fmt.Errorf("copilot: %w", err) } return req, nil } +// convertResponseFormat maps the SDK response format onto the Chat Completions +// wire shape, matching provider/openai/completions: json_schema wraps the +// schema as {"name":"response","schema":{...}} and degrades to json_object +// when no schema is given; other types send only their type. +func convertResponseFormat(rf *sdk.ResponseFormat) *chatRespFormat { + if rf == nil { + return nil + } + if rf.Type != sdk.ResponseFormatJSONSchema { + return &chatRespFormat{Type: string(rf.Type)} + } + if rf.JSONSchema == nil { + return &chatRespFormat{Type: string(sdk.ResponseFormatJSONObject)} + } + return &chatRespFormat{ + Type: string(sdk.ResponseFormatJSONSchema), + JSONSchema: &chatRespJSONSchema{ + Name: "response", + Schema: rf.JSONSchema, + }, + } +} + // convertToolChoice maps the SDK's closed ToolChoice onto the OpenAI wire form: // an unset mode leaves tool_choice unset, the three scalar modes are the string // itself, and a named tool is the function object. diff --git a/provider/github/copilot/response_format_test.go b/provider/github/copilot/response_format_test.go new file mode 100644 index 00000000..63553999 --- /dev/null +++ b/provider/github/copilot/response_format_test.go @@ -0,0 +1,82 @@ +package copilot_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/felinics/twilight/provider/github/copilot" + "github.com/felinics/twilight/sdk" + "github.com/google/jsonschema-go/jsonschema" +) + +func TestResponseFormatWireShape(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "city": {Type: "string"}, + }, + Required: []string{"city"}, + } + cases := []struct { + name string + rf *sdk.ResponseFormat + want string // "" means response_format must be omitted + }{ + {name: "unset", want: ""}, + {name: "text", rf: &sdk.ResponseFormat{Type: sdk.ResponseFormatText}, want: `{"type":"text"}`}, + {name: "json_object", rf: &sdk.ResponseFormat{Type: sdk.ResponseFormatJSONObject}, want: `{"type":"json_object"}`}, + { + name: "json_schema wraps schema with name", + rf: &sdk.ResponseFormat{Type: sdk.ResponseFormatJSONSchema, JSONSchema: schema}, + want: `{"type":"json_schema","json_schema":{"name":"response","schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}`, + }, + { + name: "json_schema without schema degrades to json_object", + rf: &sdk.ResponseFormat{Type: sdk.ResponseFormatJSONSchema}, + want: `{"type":"json_object"}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var body map[string]json.RawMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "copilotcmpl-test", + "model": "m", + "choices": []map[string]any{{ + "index": 0, + "finish_reason": "stop", + "message": map[string]any{"role": "assistant", "content": "{}"}, + }}, + }) + })) + defer srv.Close() + + p := copilot.New(copilot.WithGitHubToken("ghu_test_token"), copilot.WithBaseURL(srv.URL)) + if _, err := p.DoGenerate(context.Background(), sdk.Request{ + Model: "m", + Messages: []sdk.Message{sdk.UserMessage("Hi")}, + ResponseFormat: tc.rf, + }); err != nil { + t.Fatalf("DoGenerate: %v", err) + } + got := body["response_format"] + if tc.want == "" { + if got != nil { + t.Fatalf("response_format should be omitted, got %s", got) + } + return + } + if string(got) != tc.want { + t.Fatalf("response_format wire JSON mismatch\n got: %s\nwant: %s", got, tc.want) + } + }) + } +} diff --git a/provider/github/copilot/types.go b/provider/github/copilot/types.go index 89dccc93..50415f73 100644 --- a/provider/github/copilot/types.go +++ b/provider/github/copilot/types.go @@ -25,8 +25,17 @@ type chatStreamOptions struct { } type chatRespFormat struct { - Type string `json:"type"` - JSONSchema any `json:"json_schema,omitempty"` + Type string `json:"type"` + JSONSchema *chatRespJSONSchema `json:"json_schema,omitempty"` +} + +// chatRespJSONSchema is the Chat Completions json_schema wrapper. The schema +// itself is nested under "schema" next to a required "name"; sending the bare +// schema as json_schema is rejected by strict OpenAI-compatible upstreams. +// strict is left unset, matching the Responses provider's text.format. +type chatRespJSONSchema struct { + Name string `json:"name"` + Schema any `json:"schema"` } type chatTool struct { diff --git a/provider/openai/completions/completions.go b/provider/openai/completions/completions.go index ac197f19..8ddcdc0d 100644 --- a/provider/openai/completions/completions.go +++ b/provider/openai/completions/completions.go @@ -288,12 +288,7 @@ func (p *Provider) buildRequest(req *sdk.Request) (*chatRequest, error) { chatReq.Tools = convertTools(req.Tools) chatReq.ToolChoice = toolChoiceForWire(req.ToolChoice) } - if req.ResponseFormat != nil { - chatReq.ResponseFormat = &chatRespFormat{ - Type: string(req.ResponseFormat.Type), - JSONSchema: req.ResponseFormat.JSONSchema, - } - } + chatReq.ResponseFormat = convertResponseFormat(req.ResponseFormat) if err := p.applyChatCompletionsCompat(chatReq); err != nil { return nil, err } @@ -352,6 +347,35 @@ func (p *Provider) applyChatCompletionsCompat(req *chatRequest) error { return nil } +// responseFormatSchemaName is the json_schema name sent on the wire. The SDK +// has no per-request name, so it mirrors the Responses provider's fixed +// text.format name. +const responseFormatSchemaName = "response" + +// convertResponseFormat maps the SDK response format onto the Chat Completions +// wire shape. A json_schema format wraps the schema as +// {"name":"response","schema":{...}}; without a schema there is nothing to +// constrain against, so it degrades to json_object (plain JSON mode), which +// every OpenAI-compatible upstream accepts. Other types send only their type. +func convertResponseFormat(rf *sdk.ResponseFormat) *chatRespFormat { + if rf == nil { + return nil + } + if rf.Type != sdk.ResponseFormatJSONSchema { + return &chatRespFormat{Type: string(rf.Type)} + } + if rf.JSONSchema == nil { + return &chatRespFormat{Type: string(sdk.ResponseFormatJSONObject)} + } + return &chatRespFormat{ + Type: string(sdk.ResponseFormatJSONSchema), + JSONSchema: &chatRespJSONSchema{ + Name: responseFormatSchemaName, + Schema: rf.JSONSchema, + }, + } +} + func convertTools(tools []sdk.ToolDefinition) []chatTool { out := make([]chatTool, 0, len(tools)) for _, t := range tools { diff --git a/provider/openai/completions/response_format_test.go b/provider/openai/completions/response_format_test.go new file mode 100644 index 00000000..11a1d40a --- /dev/null +++ b/provider/openai/completions/response_format_test.go @@ -0,0 +1,134 @@ +package completions_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/felinics/twilight/provider/openai/completions" + "github.com/felinics/twilight/sdk" + "github.com/google/jsonschema-go/jsonschema" +) + +func responseFormatTestSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "city": {Type: "string"}, + }, + Required: []string{"city"}, + } +} + +// captureResponseFormat runs one DoGenerate or DoStream call against a mock +// server and returns the raw response_format bytes from the request body, or +// nil when the key was omitted. +func captureResponseFormat(t *testing.T, stream bool, rf *sdk.ResponseFormat, opts ...completions.Option) json.RawMessage { + t.Helper() + var body map[string]json.RawMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + } + if stream { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"id\":\"c\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"{}\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-test", + "model": "m", + "choices": []map[string]any{{ + "index": 0, + "finish_reason": "stop", + "message": map[string]any{"role": "assistant", "content": "{}"}, + }}, + }) + })) + defer srv.Close() + + p := completions.New(append([]completions.Option{ + completions.WithAPIKey("test-key"), + completions.WithBaseURL(srv.URL), + }, opts...)...) + req := sdk.Request{ + Model: "m", + Messages: []sdk.Message{sdk.UserMessage("Hi")}, + ResponseFormat: rf, + } + if stream { + res, err := p.DoStream(context.Background(), req) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + for range res { + } + } else if _, err := p.DoGenerate(context.Background(), req); err != nil { + t.Fatalf("DoGenerate: %v", err) + } + return body["response_format"] +} + +func TestResponseFormatWireShape(t *testing.T) { + cases := []struct { + name string + rf *sdk.ResponseFormat + opts []completions.Option + want string // "" means response_format must be omitted + }{ + { + name: "unset", + rf: nil, + want: "", + }, + { + name: "text", + rf: &sdk.ResponseFormat{Type: sdk.ResponseFormatText}, + want: `{"type":"text"}`, + }, + { + name: "json_object", + rf: &sdk.ResponseFormat{Type: sdk.ResponseFormatJSONObject}, + want: `{"type":"json_object"}`, + }, + { + name: "json_schema wraps schema with name", + rf: &sdk.ResponseFormat{Type: sdk.ResponseFormatJSONSchema, JSONSchema: responseFormatTestSchema()}, + want: `{"type":"json_schema","json_schema":{"name":"response","schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}`, + }, + { + name: "json_schema without schema degrades to json_object", + rf: &sdk.ResponseFormat{Type: sdk.ResponseFormatJSONSchema}, + want: `{"type":"json_object"}`, + }, + { + name: "json_schema under kimi compat is left as is", + rf: &sdk.ResponseFormat{Type: sdk.ResponseFormatJSONSchema, JSONSchema: responseFormatTestSchema()}, + opts: []completions.Option{completions.WithKimiChatCompletionsCompat()}, + want: `{"type":"json_schema","json_schema":{"name":"response","schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}`, + }, + } + for _, tc := range cases { + for _, stream := range []bool{false, true} { + name := tc.name + "/generate" + if stream { + name = tc.name + "/stream" + } + t.Run(name, func(t *testing.T) { + got := captureResponseFormat(t, stream, tc.rf, tc.opts...) + if tc.want == "" { + if got != nil { + t.Fatalf("response_format should be omitted, got %s", got) + } + return + } + if string(got) != tc.want { + t.Fatalf("response_format wire JSON mismatch\n got: %s\nwant: %s", got, tc.want) + } + }) + } + } +} diff --git a/provider/openai/completions/types.go b/provider/openai/completions/types.go index e7541eb2..ac83c486 100644 --- a/provider/openai/completions/types.go +++ b/provider/openai/completions/types.go @@ -32,8 +32,17 @@ type chatStreamOptions struct { } type chatRespFormat struct { - Type string `json:"type"` - JSONSchema any `json:"json_schema,omitempty"` + Type string `json:"type"` + JSONSchema *chatRespJSONSchema `json:"json_schema,omitempty"` +} + +// chatRespJSONSchema is the Chat Completions json_schema wrapper. The schema +// itself is nested under "schema" next to a required "name"; sending the bare +// schema as json_schema is rejected by strict OpenAI-compatible upstreams. +// strict is left unset, matching the Responses provider's text.format. +type chatRespJSONSchema struct { + Name string `json:"name"` + Schema any `json:"schema"` } type chatTool struct {