Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
30 changes: 24 additions & 6 deletions provider/github/copilot/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 82 additions & 0 deletions provider/github/copilot/response_format_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
13 changes: 11 additions & 2 deletions provider/github/copilot/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 30 additions & 6 deletions provider/openai/completions/completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
134 changes: 134 additions & 0 deletions provider/openai/completions/response_format_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
}
13 changes: 11 additions & 2 deletions provider/openai/completions/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading