Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ planner and runtime streaming:
| Streaming usage and stop chunks | Supported |
| Model-class routing (`default`, `high-reasoning`, `small`) | Supported |
| Structured output (`completion_delta` + final `completion`) | Supported via OpenAI `json_schema` response format, but not in combination with tools |
| Strict schemas | Tool and structured-output schemas are always sent with `strict:true`; the adapter projects canonical schemas onto the strict subset (closed objects, all members required, optionals nullable) and canonicalizes returned payloads by dropping the null members the projection introduced. Contracts strict mode cannot represent (open objects, map-style `additionalProperties`) are rejected explicitly |
| Cache options / cache checkpoints | Rejected explicitly |
| Thinking | Only the representable subset is supported: `Thinking.Enable` may map to configured OpenAI `reasoning_effort`; budgeted or interleaved thinking requests fail fast |

Expand Down
33 changes: 17 additions & 16 deletions features/model/openai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,14 @@ type (
}

// preparedRequest carries the provider-ready request plus the reversible
// mappings needed to translate tool calls back to canonical goa-ai names.
// tool projection state needed to translate tool calls back to canonical
// goa-ai names and payloads.
preparedRequest struct {
request responses.ResponseNewParams
providerToCanonical map[string]string
resolvedModelID string
resolvedModelClass model.ModelClass
structuredOutput *model.StructuredOutput
request responses.ResponseNewParams
codec *toolCodec
resolvedModelID string
resolvedModelClass model.ModelClass
structuredOutput *model.StructuredOutput
}

// responseStream is the minimal streaming surface needed by the adapter.
Expand Down Expand Up @@ -166,7 +167,7 @@ func (c *Client) Complete(ctx context.Context, req *model.Request) (*model.Respo
}
return translateResponse(
resp,
prepared.providerToCanonical,
prepared.codec,
prepared.resolvedModelID,
prepared.resolvedModelClass,
prepared.structuredOutput,
Expand All @@ -186,7 +187,7 @@ func (c *Client) Stream(ctx context.Context, req *model.Request) (model.Streamer
return newOpenAIStreamer(
ctx,
stream,
prepared.providerToCanonical,
prepared.codec,
prepared.resolvedModelID,
prepared.resolvedModelClass,
prepared.structuredOutput,
Expand All @@ -210,11 +211,11 @@ func (c *Client) prepareRequest(req *model.Request) (*preparedRequest, error) {
if modelID == "" {
return nil, errors.New("openai: model identifier is required")
}
toolDefs, canonicalToProvider, providerToCanonical, err := encodeTools(req.Tools)
toolDefs, codec, err := encodeTools(req.Tools)
if err != nil {
return nil, err
}
input, err := encodeMessages(req.Messages, canonicalToProvider)
input, err := encodeMessages(req.Messages, codec.providerNames())
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -252,7 +253,7 @@ func (c *Client) prepareRequest(req *model.Request) (*preparedRequest, error) {
}
}
if req.ToolChoice != nil {
choice, ok, err := encodeToolChoice(req.ToolChoice, canonicalToProvider)
choice, ok, err := encodeToolChoice(req.ToolChoice, codec.providerNames())
if err != nil {
return nil, err
}
Expand All @@ -261,11 +262,11 @@ func (c *Client) prepareRequest(req *model.Request) (*preparedRequest, error) {
}
}
return &preparedRequest{
request: request,
providerToCanonical: providerToCanonical,
resolvedModelID: modelID,
resolvedModelClass: modelClass,
structuredOutput: req.StructuredOutput,
request: request,
codec: codec,
resolvedModelID: modelID,
resolvedModelClass: modelClass,
structuredOutput: req.StructuredOutput,
}, nil
}

Expand Down
68 changes: 68 additions & 0 deletions features/model/openai/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,74 @@ func TestClientCompleteRoutesModelsAndToolChoice(t *testing.T) {
assert.Equal(t, responses.ToolChoiceOptionsRequired, request.ToolChoice.OfToolChoiceMode.Value)
}

func TestClientCompleteProjectsStrictToolSchemasAndCanonicalizesArguments(t *testing.T) {
transport := &mockTransport{
completeResponse: mustResponse(t, `{
"model":"gpt-4o",
"status":"completed",
"output":[
{
"id":"fc_1",
"type":"function_call",
"call_id":"call_1",
"name":"helpers_answer",
"arguments":"{\"question\":\"What is the capital of Japan?\",\"style\":null}",
"status":"completed"
}
]
}`),
}
client, err := New(Options{
DefaultModel: "gpt-4o",
transport: transport,
})
require.NoError(t, err)

resp, err := client.Complete(context.Background(), &model.Request{
Messages: []*model.Message{{
Role: model.ConversationRoleUser,
Parts: []model.Part{model.TextPart{Text: "Ping"}},
}},
Tools: []*model.ToolDefinition{{
Name: "helpers.answer",
Description: "Answer a simple question.",
Input: model.ToolInputFromSchema(rawjson.Message(`{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"question": {"type": "string", "description": "User question", "example": "What?"},
"style": {"type": "string"}
},
"example": {"question": "What is the capital of Japan?"},
"required": ["question"]
}`)),
}},
})
require.NoError(t, err)

require.Len(t, transport.completeRequests, 1)
request := transport.completeRequests[0]
require.Len(t, request.Tools, 1)
function := request.Tools[0].OfFunction
require.NotNil(t, function)
assert.True(t, function.Strict.Value)
parameters, err := json.Marshal(function.Parameters)
require.NoError(t, err)
assert.JSONEq(t, `{
"type": "object",
"additionalProperties": false,
"properties": {
"question": {"type": "string", "description": "User question"},
"style": {"type": ["string", "null"]}
},
"required": ["question", "style"]
}`, string(parameters))

require.Len(t, resp.ToolCalls, 1)
assert.Equal(t, tools.Ident("helpers.answer"), resp.ToolCalls[0].Name)
assert.JSONEq(t, `{"question":"What is the capital of Japan?"}`, string(resp.ToolCalls[0].Payload))
}

func TestClientCompleteRejectsMissingRequestedModelClassConfig(t *testing.T) {
tests := []struct {
name string
Expand Down
23 changes: 8 additions & 15 deletions features/model/openai/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ type (

toolCalls map[string]*streamToolBuffer

toolNameMap map[string]string
modelID string
modelClass model.ModelClass
output *model.StructuredOutput
codec *toolCodec
modelID string
modelClass model.ModelClass
output *model.StructuredOutput

completed bool
sawText bool
Expand All @@ -60,7 +60,7 @@ type (
func newOpenAIStreamer(
ctx context.Context,
stream responseStream,
toolNameMap map[string]string,
codec *toolCodec,
modelID string,
modelClass model.ModelClass,
output *model.StructuredOutput,
Expand All @@ -76,7 +76,7 @@ func newOpenAIStreamer(
emit: streamer.emitChunk,
recordUsage: streamer.recordUsage,
toolCalls: make(map[string]*streamToolBuffer),
toolNameMap: toolNameMap,
codec: codec,
modelID: modelID,
modelClass: modelClass,
output: output,
Expand Down Expand Up @@ -249,7 +249,7 @@ func (p *openAIChunkProcessor) registerOutputItem(item responses.ResponseOutputI
buffer.callID = actual.CallID
}
if actual.Name != "" {
buffer.name = canonicalToolName(actual.Name, p.toolNameMap)
buffer.name = p.codec.canonicalName(actual.Name)
}
return p.flushPendingToolDeltas(buffer)
default:
Expand Down Expand Up @@ -352,7 +352,7 @@ func (p *openAIChunkProcessor) handleThinkingDelta(event responses.ResponseReaso
func (p *openAIChunkProcessor) handleCompleted(resp responses.Response) error {
p.completed = true
p.modelID = chooseModelID(resp.Model, p.modelID)
translated, err := translateResponse(&resp, p.toolNameMap, p.modelID, p.modelClass, p.output)
translated, err := translateResponse(&resp, p.codec, p.modelID, p.modelClass, p.output)
if err != nil {
return err
}
Expand Down Expand Up @@ -411,13 +411,6 @@ func (p *openAIChunkProcessor) handleCompleted(resp responses.Response) error {
})
}

func canonicalToolName(providerName string, providerToCanonical map[string]string) string {
if canonical, ok := providerToCanonical[providerName]; ok {
return canonical
}
return providerName
}

func structuredOutputName(output *model.StructuredOutput) string {
if output == nil || output.Name == "" {
return structuredOutputDefaultName
Expand Down
Loading