Files
LocalAI/core/http/endpoints/openresponses/responses.go
T
mudler-agentandEttore Di Giacinto e4fa051ee3 refactor(distributed): put the NATS-only paths behind interfaces (#12395)
* feat(messaging): add shared subject rules

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(messaging): cover BroadcastRoots, ControlRoots and SubjectRoot

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(messaging): add Broadcaster and enforce subject rules in every carrier

Broadcaster is the fan-out half of MessagingClient. The NATS client and
the in-memory FakeBus now refuse a subject outside the served roots and any
wildcard other than a whole single token, and FakeBus shares MatchSubject
instead of its own copy. FakeBus Unsubscribe now removes its own
subscription instead of the first one with the same subject.

A shared conformance suite in messagingtest runs against both carriers.
The distributed e2e specs that used invented test.* subjects, and the one
that subscribed with a > filter, now use subjects from subjects.go.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: depend on Broadcaster where only publish and subscribe are used

Narrowed to messaging.Broadcaster: nodes/staging_progress.go,
nodes/install_progress_publisher.go, galleryop/operation.go,
galleryop/service.go, agentpool/user_services.go, agentpool/agent_jobs.go,
openresponses/store.go, openresponses/sync.go, syncstate/syncstate.go,
finetune/service.go, quantization/service.go and
failover/distsync/distsync.go. SubscribeJSON now takes a Broadcaster
because it only calls Subscribe, which lets the narrowed consumers use it.

Stayed wide: worker/supervisor.go, because its client field also serves
the SubscribeReply handlers in worker/lifecycle.go. The request/reply,
queue and wiring files (nodes/unloader.go, nodes/file_stager_s3.go,
jobs/dispatcher.go, agents/dispatcher.go, agents/events.go,
worker/file_staging.go, cli/agent_worker.go, http/app.go) are unchanged by
design.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): name the no-route condition and confine the carrier error

Consumers matched nats.ErrNoResponders, which names an absence, to demote a node. They now match ErrNoRoute, the control path maps the carrier's failure onto it, and timeouts and worker refusals are pinned as not being no-route.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(nodes): state which FileStager implementations return ErrNoRoute

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): build backend clients through one node-aware seam

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: describe the distributed transport seams

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: correct comments that overclaim after the seams refactor

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(agent-worker): refuse an unserved LOCALAI_AGENT_SUBJECT at startup

The messaging client now refuses a subject whose root no carrier serves.
An agent worker started with a custom LOCALAI_AGENT_SUBJECT such as
tenant-a.agent.execute used to start and then wait on a subject the
frontend never publishes to. After the subject rules landed it exited
at subscribe time with an error that did not name the setting.

Behaviour change: the worker now checks LOCALAI_AGENT_SUBJECT before it
registers or connects, and exits with an error that names the variable
and says to use a served subject under the agent root, for example
agent.execute. The served roots are not widened: a custom root was
never delivered by the frontend, and a wider set would reopen the
drift the subject rules exist to close. The flag help and the agent
worker docs state the constraint.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(nodes): pin the reactions to ErrNoRoute

Three callers react to ErrNoRoute and had no spec: the reconciler's
upgrade drain falls back to the legacy forced install, the reconciler
marks the node unhealthy when a pending op has no route, and the
backend-op fan-out marks the node unhealthy. Each spec drives the real
caller with a scripted no-responders reply and reads the result from
the registry or the recorded requests.

A fourth spec pins the other side: a pending op that times out leaves
the node healthy and only counts the attempt, so mapping timeouts onto
ErrNoRoute would fail here.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(messaging): pin client subject checks and fail the carrier suite in CI

Add specs that call Publish, Request, Subscribe, QueueSubscribe,
SubscribeReply and QueueSubscribeReply on a client with no connection.
Each call must return ErrUnservedSubject for bogus.thing and
ErrUnsupportedWildcard for jobs.>. This proves that the subject check
runs before the connection is used, and needs no server.

The NATS conformance suite is the only check that runs the subject rules
against a real carrier. Before this change it skipped without output
when Docker was missing. Now it fails when CI is set, so a Linux runner
without Docker cannot hide it. It still skips on local runs and on macOS
CI, which has no Docker.

Add SubjectNodeBackendInstallProgress to the list of constructors that
must build served subjects, and ask contributors to extend the list.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: state what ErrNoRoute may change, and group the distributed guides

The seams note said MarkUnhealthy was the only state change allowed
on ErrNoRoute. A pending backend op still records the failed attempt,
counts toward the reconciler's retry limit and is dead-lettered after
the maximum attempts. The note now says that MarkUnhealthy is the only
change to the node's own state, and that the per-op accounting is not
a verdict about the node.

The note also documents that the NATS conformance run fails under CI
when Docker is missing. The distributed-seams row moves next to the
distributed-state row in the topics table. The liveness ping spec
header now says no route is a reason to skip the worker, not proof
that the worker is gone.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): give the backend client factory the node id

Mechanical: the method gains a nodeID parameter and the eight test fakes are updated. No behaviour change.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): drop the optional node-aware factory

The node id is now in the main method, so the optional interface and its helper had no behaviour of their own.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): dial backend probes through the client factory

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): dial workers' file servers through a per-node dialer

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(http): proxy backend logs through the per-node worker dialer

The admin backend-logs proxy (list, lines and the WebSocket stream) now reaches a worker through the same per-node dialer as the HTTP file stager, so every frontend-to-worker dial goes through one seam. The shared direct dialer keeps alive for 15s where the proxy used 30s. Harmless for requests bounded at 15s.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(http): keep the backend-logs proxy independent of the admin connection

The proxy request had no context before the dialer change and is bounded only by its 15s timeout. Keep it that way.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: move the worker control payloads to workerctl

Mechanical move of the request and reply structs, the install progress event and the file payloads out of messaging. The verbs no longer belong to one carrier. No alias is left behind.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(worker): serve the lifecycle verbs through a controlServer

The worker registers one handler per verb and a NATS server maps each verb to its subject. Registration errors now name the verb. node.stop is served with SubscribeReply, which is identical on the wire because the handler never replies.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(worker): report install progress through the control sink

Install and upgrade now emit download progress through the sink the control server hands them. The debounce and the terminal flush stay in the handler path, built over that sink by the new nodes.NewDebouncedInstallProgressSink, which replaces NewDebouncedInstallProgressPublisher. The subject and payload on the wire are unchanged. The supervisor no longer holds the bus, and installFn and upgradeFn let specs drive both verbs without a gallery.

The malformed-request log lines are restored for install, upgrade, backend.delete, model.unload, model.stop and model.delete, with the reply bytes unchanged. The signal adapter is renamed noReply, which also lets worker.go import os/signal without an alias again.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(worker): serve the file-staging verbs through a controlServer

An empty list-dir answer is now {} rather than {"files":null}, because the typed reply omits an empty Files slice. The frontend decodes both to a nil slice in nodes/file_stager_s3.go.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(messaging): add WorkQueue and the NATS producer

This is the producer side of the competing-consumer seam. The work kinds map one to one to today's subjects and queue groups: task to jobs.new and mcp-ci to jobs.mcp-ci.new (both in group workers), agent-run to agent.execute (group agent-workers). Enqueue publishes the payload as Publish does today, with one JSON marshal. FakeBus now records queue groups and keeps reply handlers so later specs can pin and drive them.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(messaging): add the NATS WorkConsumer

An in-flight limit of one runs the handler inline on the delivery goroutine, as the MCP CI consumer does today. Any other limit spawns per delivery, as the agent consumer does. Queue groups are unchanged.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: publish queued work through WorkQueue

The job dispatcher, the agent pool and the agent scheduler enqueue through messaging.WorkQueue; the NATS implementation publishes to the same subjects as before. DistributedServices builds the queue next to the NATS client and hands it to the dispatcher and the agent pool, whose distributed mode switch now reads a non-nil WorkQueue. The unused AgentPoolService.SetNATSClient is removed.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: consume queued work through WorkConsumer

The agent dispatcher and the MCP CI consumer register through messaging.WorkConsumer. The NATS implementation keeps the inline one-at-a-time model for MCP CI and the per-delivery model for agent runs. handleMCPCIJob reports on the events publisher the carrier hands it instead of a captured client.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: delete the consumers nothing in production reached

jobs.new has a producer and no production consumer, and the agent dispatcher's Dispatch was only called from tests. Publishing jobs.new is unchanged.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(mcp): send MCP requests to agent workers through AgentControl

Timeouts still honour only the deadline, not cancellation, exactly as today. The NATS no-responders error maps to ErrNoRoute and a timeout does not.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(agent-worker): serve MCP requests and backend.stop through agentRPCServer

The agent worker's MCP tool and discovery reply subscriptions and its backend stop listener move behind an unexported agentRPCServer interface, served on NATS by nodes.NATSAgentRPCServer. The handlers become typed mcp.ToolHandler and mcp.DiscoveryHandler values that answer every failure with a reply carrying Error.

Queue group (agent-workers), inline execution on the delivery goroutine, the background handler context, the unmarshal error reply texts and the reply-less backend stop subscription are unchanged. The backend stop handler takes the decoded backend name, so it can still close that backend's MCP sessions.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(messaging): remove helpers that only tests used

BroadcastRoots, ControlRoots and SubjectRoot had no production caller. The roots spec now asserts every served root through ValidateSubject instead. MatchSubject moves back into the test support package, the only place that used it, with its table. NATSAgentRPCServer drops the subscription list it stored and never read, and NewNATSAgentRPCServer gets a doc comment.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(mcp): round trip the agent RPC server over a real NATS server

One spec sends a tool request and a discovery request through NATSAgentControl to NATSAgentRPCServer and checks that the handlers see the decoded requests and the replies come back. It also puts an undecodable body on the tool subject and checks the server answers with an unmarshal error instead of leaving the requester to time out.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: describe the distributed transport seams

The developer note now lists the final seams: fan-out, queues, both halves of the control verbs and of agent RPC, and the dial. It records the open items a second carrier has to handle.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test: pin the in-flight limit each queue consumer asks for

The work queue specs pin what Consume does for a given limit, but nothing
pinned which limit each production consumer passes. Changing the agent
worker's MCP CI limit from 1 to 0 would have let MCP CI jobs run
concurrently on each worker with every test green.

Move the MCP CI Consume call into startMCPCIConsumer with the same wiring
and pin that it asks for (WorkMCPCI, 1). Pin that NATSDispatcher.Start asks
for (WorkAgentRun, maxConcurrent) for several limits.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: remove helpers the branch left without a caller

SubjectJobCancelWildcard lost its last subscriber when the frontend
stopped listening on jobs.*.cancel; the NATS permissions and conformance
suite spell the subject out, so nothing reads the constant.

decodeBackendStopRequest returned a stopAll flag that production dropped
and only a test read. decodeBackendStop is now the single decoder with the
same semantics: an empty body is stop-all, an empty Backend is stop-all,
malformed JSON is an error. stopBackends still derives stop-all from
Backend, so no reply changes.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(messaging): keep an explicitly empty agent queue a plain subscription

Before the work queue seam the agent worker passed LOCALAI_AGENT_QUEUE
straight to QueueSubscribe, so an explicitly empty value made a plain
subscription and every agent worker ran every agent run.
WithAgentRunRoute replaced an empty queue with agent-workers, which
silently changed that.

Keep the queue as given once the option is applied. An empty subject still
falls back to agent.execute, since it never had a meaning of its own. The
flag default stays agent-workers, so only an explicitly empty value
reaches this.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: correct comments and record the PR B notes

Fix the recordingFactory comment (it also records the parallel flag),
document that a negative maxInFlight is unbounded and that Unsubscribe from
a handler deadlocks, and say a permanently undecodable payload returns nil.

Record controlHandler's undecodable return as a kept exception, and add the
second carrier notes to the developer note: the reconciler has no
ClientFactory option, the logs proxy honours HTTP_PROXY, verbs one carrier
serves need an opt-out, terminal replies come from the result event, and
agent runs publish through the NATS-bound EventBridge, which is not an
additive change.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-02 23:58:41 +02:00

3248 lines
111 KiB
Go

package openresponses
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
openaiEndpoint "github.com/mudler/LocalAI/core/http/endpoints/openai"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
reason "github.com/mudler/LocalAI/pkg/reasoning"
"github.com/mudler/LocalAI/pkg/utils"
"github.com/mudler/xlog"
)
// ResponsesEndpoint is the Open Responses API endpoint
// https://www.openresponses.org/specification
// @Summary Create a response using the Open Responses API
// @Tags inference
// @Param request body schema.OpenResponsesRequest true "Request body"
// @Success 200 {object} schema.ORResponseResource "Response"
// @Router /v1/responses [post]
func ResponsesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, agentControl mcpTools.AgentControl) echo.HandlerFunc {
return func(c echo.Context) error {
createdAt := time.Now().Unix()
responseID := fmt.Sprintf("resp_%s", uuid.New().String())
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenResponsesRequest)
if !ok || input.Model == "" {
return sendOpenResponsesError(c, 400, "invalid_request", "model is required", "")
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return sendOpenResponsesError(c, 400, "invalid_request", "model configuration not found", "")
}
// Initialize store with TTL from appConfig
store := GetGlobalStore()
if appConfig.OpenResponsesStoreTTL > 0 {
store.SetTTL(appConfig.OpenResponsesStoreTTL)
}
// Check if storage is disabled for this request
shouldStore := true
if input.Store != nil && !*input.Store {
shouldStore = false
}
// Handle previous_response_id if provided.
var messages []schema.Message
if input.PreviousResponseID != "" {
previousMessages, err := resolvePreviousResponseMessages(store, input.PreviousResponseID, cfg, ownerFromContext(c))
if err != nil {
if notFound, ok := err.(*previousResponseNotFoundError); ok {
return sendOpenResponsesError(c, 404, "not_found", notFound.Error(), "previous_response_id")
}
return sendOpenResponsesError(c, 400, "invalid_request", err.Error(), "")
}
messages = previousMessages
}
// Convert Open Responses input to internal Messages
newMessages, err := convertORInputToMessages(input.Input, cfg)
if err != nil {
return sendOpenResponsesError(c, 400, "invalid_request", fmt.Sprintf("failed to parse input: %v", err), "")
}
// Append new input messages
messages = append(messages, newMessages...)
// Add instructions as system message if provided
if input.Instructions != "" {
messages = append([]schema.Message{{Role: "system", Content: input.Instructions, StringContent: input.Instructions}}, messages...)
}
// Handle tools
var funcs functions.Functions
var shouldUseFn bool
var mcpExecutor mcpTools.ToolExecutor
if len(input.Tools) > 0 {
funcs, shouldUseFn = convertORToolsToFunctions(input, cfg)
}
// MCP injection: prompts, resources, and tools
mcpServers := mcpTools.MCPServersFromMetadata(input.Metadata)
mcpPromptName, mcpPromptArgs := mcpTools.MCPPromptFromMetadata(input.Metadata)
mcpResourceURIs := mcpTools.MCPResourcesFromMetadata(input.Metadata)
hasMCPRequest := len(mcpServers) > 0 || mcpPromptName != "" || len(mcpResourceURIs) > 0
hasMCPConfig := cfg.MCP.Servers != "" || cfg.MCP.Stdio != ""
if (hasMCPRequest && hasMCPConfig) || (len(input.Tools) == 0 && hasMCPConfig) {
remote, stdio, mcpErr := cfg.MCP.MCPConfigFromYAML()
if mcpErr == nil {
enabledServers := mcpServers
if !hasMCPRequest {
enabledServers = nil // backward compat: auto-activate all servers
}
mcpExecutor = mcpTools.NewToolExecutor(c.Request().Context(), agentControl, cfg.Name, remote, stdio, enabledServers)
// Prompt and resource injection (pre-processing step — resolves locally regardless of distributed mode)
if hasMCPRequest {
namedSessions, sessErr := mcpTools.NamedSessionsFromMCPConfig(cfg.Name, remote, stdio, mcpServers)
if sessErr == nil && len(namedSessions) > 0 {
mcpCtx, _ := mcpTools.InjectMCPContext(c.Request().Context(), namedSessions, mcpPromptName, mcpPromptArgs, mcpResourceURIs)
if mcpCtx != nil {
messages = append(mcpCtx.PromptMessages, messages...)
mcpTools.AppendResourceSuffix(messages, mcpCtx.ResourceSuffix)
}
}
}
// Tool injection via executor
if mcpExecutor.HasTools() {
mcpFuncs, discErr := mcpExecutor.DiscoverTools(c.Request().Context())
if discErr == nil {
for _, fn := range mcpFuncs {
funcs = append(funcs, fn)
input.Tools = append(input.Tools, schema.ORFunctionTool{
Type: "function",
Name: fn.Name,
Description: fn.Description,
Parameters: fn.Parameters,
})
}
shouldUseFn = len(funcs) > 0 && cfg.ShouldUseFunctions()
xlog.Debug("Open Responses MCP tools injected", "count", len(mcpFuncs), "total_funcs", len(funcs))
}
}
} else {
xlog.Error("Failed to parse MCP config", "error", mcpErr)
}
}
// Create OpenAI-compatible request for internal processing
openAIReq := &schema.OpenAIRequest{
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: input.Model},
Temperature: input.Temperature,
TopP: input.TopP,
Maxtokens: input.MaxOutputTokens,
},
Messages: messages,
Stream: input.Stream,
Context: input.Context,
Cancel: input.Cancel,
Functions: funcs,
}
// Handle text_format -> response_format conversion
if input.TextFormat != nil {
openAIReq.ResponseFormat = convertTextFormatToResponseFormat(input.TextFormat)
}
// Generate grammar for function calling (similar to OpenAI chat endpoint)
if shouldUseFn && !cfg.FunctionsConfig.GrammarConfig.NoGrammar {
// Add no-action function to allow model to respond without calling a tool
noActionName := "answer"
noActionDescription := "use this action to answer without performing any action"
if cfg.FunctionsConfig.NoActionFunctionName != "" {
noActionName = cfg.FunctionsConfig.NoActionFunctionName
}
if cfg.FunctionsConfig.NoActionDescriptionName != "" {
noActionDescription = cfg.FunctionsConfig.NoActionDescriptionName
}
noActionGrammar := functions.Function{
Name: noActionName,
Description: noActionDescription,
Parameters: map[string]any{
"properties": map[string]any{
"message": map[string]any{
"type": "string",
"description": "The message to reply the user with",
},
},
},
}
// Make a copy of funcs to avoid modifying the original
funcsWithNoAction := make(functions.Functions, len(funcs))
copy(funcsWithNoAction, funcs)
// Append no-action function unless disabled
if !cfg.FunctionsConfig.DisableNoAction {
funcsWithNoAction = append(funcsWithNoAction, noActionGrammar)
}
// Force picking one of the functions by the request
if cfg.FunctionToCall() != "" {
funcsWithNoAction = funcsWithNoAction.Select(cfg.FunctionToCall())
}
// Generate grammar to constrain model output to valid function calls
jsStruct := cfg.FunctionsConfig.ToJSONStructure(funcsWithNoAction)
g, err := jsStruct.Grammar(cfg.FunctionsConfig.GrammarOptions()...)
if err == nil {
cfg.Grammar = g
xlog.Debug("Open Responses - Generated grammar for function calling")
} else {
xlog.Error("Open Responses - Failed generating grammar for function calling", "error", err)
}
}
// Template the prompt
predInput := evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, funcs, shouldUseFn)
xlog.Debug("Open Responses - Prompt (after templating)", "prompt", predInput)
// Handle background mode
isBackground := input.Background != nil && *input.Background
if isBackground {
// Background mode requires storage
if !shouldStore {
return sendOpenResponsesError(c, 400, "invalid_request_error", "background=true requires store=true", "background")
}
// Create initial response with "queued" status
queuedResponse := buildORResponse(responseID, createdAt, nil, schema.ORStatusQueued, input, []schema.ORItemField{}, nil, true)
// Create cancellable context for background execution
bgCtx, bgCancel := context.WithCancel(context.Background())
// Store the background response and stamp its owner before the ID
// is returned to the client, so later GET/cancel/resume can verify
// the caller owns it.
store.StoreBackgroundOwned(responseID, input, queuedResponse, bgCancel, input.Stream, ownerFromContext(c))
// Start background processing goroutine
go func() {
defer bgCancel()
// Update status to in_progress
store.UpdateStatus(responseID, schema.ORStatusInProgress, nil)
var finalResponse *schema.ORResponseResource
var bgErr error
if input.Stream {
// Background streaming processing (buffer events)
finalResponse, bgErr = handleBackgroundStream(bgCtx, store, responseID, createdAt, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, true, mcpExecutor, evaluator)
} else {
// Background non-streaming processing
finalResponse, bgErr = handleBackgroundNonStream(bgCtx, store, responseID, createdAt, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, mcpExecutor, evaluator)
}
if bgErr != nil {
xlog.Error("Background response failed", "response_id", responseID, "error", bgErr)
now := time.Now().Unix()
store.UpdateStatus(responseID, schema.ORStatusFailed, &now)
return
}
// Update final response in store
if finalResponse != nil {
store.UpdateResponse(responseID, finalResponse)
}
}()
// Return immediately with queued response
return c.JSON(200, queuedResponse)
}
if input.Stream {
return handleOpenResponsesStream(c, responseID, createdAt, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, shouldStore, mcpExecutor, evaluator)
}
return handleOpenResponsesNonStream(c, responseID, createdAt, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, shouldStore, mcpExecutor, evaluator, 0)
}
}
// convertORInputToMessages converts Open Responses input to internal Messages
func convertORInputToMessages(input any, cfg *config.ModelConfig) ([]schema.Message, error) {
var messages []schema.Message
switch v := input.(type) {
case string:
// Simple string = user message
return []schema.Message{{Role: "user", Content: v, StringContent: v}}, nil
case []any:
// Array of items
for _, itemRaw := range v {
itemMap, ok := itemRaw.(map[string]any)
if !ok {
continue
}
itemType, _ := itemMap["type"].(string)
// OpenAI SDK helpers (e.g. client.responses.create(input=[{"role":...,"content":...}]))
// send message items without a "type" discriminator. Treat a bare {role, content}
// object as type:"message" so the chat-completions and responses paths agree.
if itemType == "" {
if _, hasRole := itemMap["role"].(string); hasRole {
if _, hasContent := itemMap["content"]; hasContent {
itemType = "message"
}
}
}
switch itemType {
case "message":
msg, err := convertORMessageItem(itemMap, cfg)
if err != nil {
return nil, err
}
messages = append(messages, msg)
case "reasoning":
msg, err := convertORReasoningItemToMessage(itemMap)
if err != nil {
return nil, err
}
messages = append(messages, msg)
case "function_call":
msg, err := convertORFunctionCallItemToMessage(itemMap)
if err != nil {
return nil, err
}
messages = append(messages, msg)
case "function_call_output":
// Convert function call output to tool role message
callID, _ := itemMap["call_id"].(string)
output := itemMap["output"]
var outputStr string
if str, ok := output.(string); ok {
outputStr = str
} else {
// Convert to JSON string
outputBytes, _ := json.Marshal(output)
outputStr = string(outputBytes)
}
// For tool messages, we use the Name field to store the call ID
messages = append(messages, schema.Message{
Role: "tool",
Name: callID,
Content: outputStr,
StringContent: outputStr,
})
case "item_reference":
// Handle item references - look up item in stored responses
// According to spec, item_reference uses "id" field, not "item_id"
itemID, ok := itemMap["id"].(string)
if !ok || itemID == "" {
return nil, fmt.Errorf("item_reference missing id")
}
store := GetGlobalStore()
item, responseID, err := store.FindItem(itemID)
if err != nil {
return nil, fmt.Errorf("item not found: %s (from response %s): %w", itemID, responseID, err)
}
// Log item reference resolution for debugging
xlog.Debug("Resolved item reference", "item_id", itemID, "response_id", responseID, "item_type", item.Type)
// Convert referenced item to message based on its type
msg, err := convertORItemToMessage(item, responseID)
if err != nil {
return nil, fmt.Errorf("failed to convert referenced item %s from response %s: %w", itemID, responseID, err)
}
messages = append(messages, msg)
}
}
return mergeContiguousAssistantMessages(messages), nil
default:
return nil, fmt.Errorf("unsupported input type: %T", input)
}
}
// convertORReasoningItemToMessage converts an Open Responses reasoning item to an assistant Message fragment (for merging).
func convertORReasoningItemToMessage(itemMap map[string]any) (schema.Message, error) {
var reasoning string
if content := itemMap["content"]; content != nil {
if s, ok := content.(string); ok {
reasoning = s
} else if parts, ok := content.([]any); ok {
for _, p := range parts {
if partMap, ok := p.(map[string]any); ok {
if t, _ := partMap["type"].(string); (t == "output_text" || t == "input_text") && partMap["text"] != nil {
if tStr, ok := partMap["text"].(string); ok {
reasoning += tStr
}
}
}
}
}
}
return schema.Message{Role: "assistant", Reasoning: stringPtr(reasoning)}, nil
}
// convertORFunctionCallItemToMessage converts an Open Responses function_call item to an assistant Message fragment (for merging).
func convertORFunctionCallItemToMessage(itemMap map[string]any) (schema.Message, error) {
callID, _ := itemMap["call_id"].(string)
name, _ := itemMap["name"].(string)
arguments, _ := itemMap["arguments"].(string)
if callID == "" {
callID = fmt.Sprintf("call_%s", name)
}
return schema.Message{
Role: "assistant",
ToolCalls: []schema.ToolCall{{
Index: 0,
ID: callID,
Type: "function",
FunctionCall: schema.FunctionCall{Name: name, Arguments: arguments},
}},
}, nil
}
func stringPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
// convertORItemToMessage converts a single ORItemField to a Message
// responseID is the ID of the response where this item was found (for logging/debugging)
func convertORItemToMessage(item *schema.ORItemField, responseID string) (schema.Message, error) {
switch item.Type {
case "message":
// Convert message item to message
var textContent string
if contentParts, ok := item.Content.([]schema.ORContentPart); ok {
for _, part := range contentParts {
if part.Type == "output_text" || part.Type == "input_text" {
textContent += part.Text
}
}
} else if str, ok := item.Content.(string); ok {
textContent = str
}
return schema.Message{
Role: item.Role,
StringContent: textContent,
Content: textContent,
}, nil
case "function_call_output":
// Convert function call output to tool role message
var outputStr string
if str, ok := item.Output.(string); ok {
outputStr = str
} else {
// Convert to JSON string
outputBytes, _ := json.Marshal(item.Output)
outputStr = string(outputBytes)
}
return schema.Message{
Role: "tool",
Name: item.CallID,
Content: outputStr,
StringContent: outputStr,
}, nil
case "reasoning":
reasoning := extractReasoningContentFromORItem(item)
return schema.Message{Role: "assistant", Reasoning: stringPtr(reasoning)}, nil
case "function_call":
callID := item.CallID
if callID == "" {
callID = fmt.Sprintf("call_%s", item.Name)
}
return schema.Message{
Role: "assistant",
ToolCalls: []schema.ToolCall{{
Index: 0,
ID: callID,
Type: "function",
FunctionCall: schema.FunctionCall{Name: item.Name, Arguments: item.Arguments},
}},
}, nil
default:
return schema.Message{}, fmt.Errorf("unsupported item type for conversion: %s (from response %s)", item.Type, responseID)
}
}
func extractReasoningContentFromORItem(item *schema.ORItemField) string {
if contentParts, ok := item.Content.([]schema.ORContentPart); ok {
var s string
for _, part := range contentParts {
if part.Type == "output_text" || part.Type == "input_text" {
s += part.Text
}
}
return s
}
if s, ok := item.Content.(string); ok {
return s
}
return ""
}
type previousResponseNotFoundError struct {
ResponseID string
}
func (e *previousResponseNotFoundError) Error() string {
return fmt.Sprintf("previous response not found: %s", e.ResponseID)
}
// resolvePreviousResponseMessages reconstructs the complete stored conversation
// ending at responseID. Requests are stored as incremental deltas, so replaying
// only the immediately previous request loses older turns after the first chain.
func resolvePreviousResponseMessages(store *ResponseStore, responseID string, cfg *config.ModelConfig, callerID string) ([]schema.Message, error) {
messages, _, err := resolvePreviousResponseMessagesFromSources(
[]previousResponseStoreSource{{store: store}},
responseID,
cfg,
callerID,
)
return messages, err
}
type previousResponseStoreSource struct {
store *ResponseStore
connectionLocal bool
}
// resolvePreviousResponseMessagesFromSources resolves each hop against the
// stores in priority order and reports whether the resulting chain contains
// connection-local state. Every hop is owner-checked so a known response ID
// cannot be used to replay another caller's conversation.
func resolvePreviousResponseMessagesFromSources(sources []previousResponseStoreSource, responseID string, cfg *config.ModelConfig, callerID string) ([]schema.Message, bool, error) {
type chainEntry struct {
id string
request schema.OpenResponsesRequest
response schema.ORResponseResource
}
var chain []chainEntry
usedConnectionLocal := false
seen := make(map[string]struct{})
for currentID := responseID; currentID != ""; {
if _, exists := seen[currentID]; exists {
return nil, false, fmt.Errorf("previous_response_id cycle detected at %s", currentID)
}
seen[currentID] = struct{}{}
var stored *StoredResponse
var connectionLocal bool
for _, source := range sources {
if source.store == nil {
continue
}
candidate, err := source.store.Get(currentID)
if err == nil {
if !accessAllowed(candidate, callerID) {
return nil, false, &previousResponseNotFoundError{ResponseID: currentID}
}
stored = candidate
connectionLocal = source.connectionLocal
break
}
}
if stored == nil {
return nil, false, &previousResponseNotFoundError{ResponseID: currentID}
}
stored.mu.RLock()
if stored.Request == nil || stored.Response == nil {
stored.mu.RUnlock()
return nil, false, fmt.Errorf("stored previous response %s is incomplete", currentID)
}
request := *stored.Request
response := *stored.Response
response.Output = append([]schema.ORItemField(nil), stored.Response.Output...)
stored.mu.RUnlock()
chain = append(chain, chainEntry{id: currentID, request: request, response: response})
usedConnectionLocal = usedConnectionLocal || connectionLocal
currentID = request.PreviousResponseID
}
var messages []schema.Message
for i := len(chain) - 1; i >= 0; i-- {
entry := chain[i]
inputMessages, err := convertORInputToMessages(entry.request.Input, cfg)
if err != nil {
return nil, false, fmt.Errorf("failed to convert previous input for %s: %w", entry.id, err)
}
outputMessages, err := convertOROutputItemsToMessages(entry.response.Output)
if err != nil {
return nil, false, fmt.Errorf("failed to convert previous response %s: %w", entry.id, err)
}
messages = append(messages, inputMessages...)
messages = append(messages, outputMessages...)
}
return messages, usedConnectionLocal, nil
}
// convertOROutputItemsToMessages converts Open Responses output items to internal Messages.
// Contiguous assistant items (message, reasoning, function_call) are merged into a single message.
func convertOROutputItemsToMessages(outputItems []schema.ORItemField) ([]schema.Message, error) {
var messages []schema.Message
for _, item := range outputItems {
switch item.Type {
case "message":
var textContent string
if contentParts, ok := item.Content.([]schema.ORContentPart); ok && len(contentParts) > 0 {
for _, part := range contentParts {
if part.Type == "output_text" {
textContent += part.Text
}
}
}
messages = append(messages, schema.Message{
Role: item.Role,
StringContent: textContent,
Content: textContent,
})
case "reasoning":
reasoning := extractReasoningContentFromORItem(&item)
messages = append(messages, schema.Message{Role: "assistant", Reasoning: stringPtr(reasoning)})
case "function_call":
msg := schema.Message{
Role: "assistant",
ToolCalls: []schema.ToolCall{{
Index: 0,
ID: item.CallID,
Type: "function",
FunctionCall: schema.FunctionCall{Name: item.Name, Arguments: item.Arguments},
}},
}
if msg.ToolCalls[0].ID == "" {
msg.ToolCalls[0].ID = fmt.Sprintf("call_%s", item.Name)
}
messages = append(messages, msg)
case "function_call_output":
// Convert function call output to tool role message
var outputStr string
if str, ok := item.Output.(string); ok {
outputStr = str
} else {
// Convert to JSON string
outputBytes, _ := json.Marshal(item.Output)
outputStr = string(outputBytes)
}
messages = append(messages, schema.Message{
Role: "tool",
Name: item.CallID,
Content: outputStr,
StringContent: outputStr,
})
}
}
return mergeContiguousAssistantMessages(messages), nil
}
// mergeContiguousAssistantMessages merges contiguous assistant messages into one.
// Many chat templates expect content, reasoning, and tool calls in a single assistant message
// (see e.g. llama.cpp PR 19773). This avoids creating separate messages per input item.
func mergeContiguousAssistantMessages(messages []schema.Message) []schema.Message {
if len(messages) == 0 {
return messages
}
var out []schema.Message
var acc *schema.Message
for i := range messages {
m := &messages[i]
if m.Role != "assistant" {
flushAssistantAccumulator(&out, &acc)
out = append(out, *m)
continue
}
if acc == nil {
acc = &schema.Message{Role: "assistant"}
}
if m.StringContent != "" {
if acc.StringContent != "" {
acc.StringContent += "\n" + m.StringContent
} else {
acc.StringContent = m.StringContent
}
if acc.Content == nil {
acc.Content = m.Content
} else if _, ok := m.Content.(string); ok {
acc.Content = acc.StringContent
}
}
if m.Reasoning != nil && *m.Reasoning != "" {
if acc.Reasoning == nil {
acc.Reasoning = m.Reasoning
} else {
combined := *acc.Reasoning + "\n" + *m.Reasoning
acc.Reasoning = &combined
}
}
if len(m.ToolCalls) > 0 {
acc.ToolCalls = append(acc.ToolCalls, m.ToolCalls...)
}
}
flushAssistantAccumulator(&out, &acc)
return out
}
func flushAssistantAccumulator(out *[]schema.Message, acc **schema.Message) {
if acc == nil || *acc == nil {
return
}
m := *acc
if m.StringContent == "" && (m.Reasoning == nil || *m.Reasoning == "") && len(m.ToolCalls) == 0 {
*acc = nil
return
}
if m.Content == nil {
m.Content = m.StringContent
}
// Re-index tool calls after merge (each may have been 0)
for i := range m.ToolCalls {
m.ToolCalls[i].Index = i
}
*out = append(*out, *m)
*acc = nil
}
// convertORMessageItem converts an Open Responses message item to internal Message
func convertORMessageItem(itemMap map[string]any, cfg *config.ModelConfig) (schema.Message, error) {
role, _ := itemMap["role"].(string)
msg := schema.Message{Role: role}
content := itemMap["content"]
switch contentVal := content.(type) {
case string:
msg.StringContent = contentVal
msg.Content = contentVal
case []any:
// Array of content parts
var textContent string
var stringImages []string
var stringVideos []string
var stringAudios []string
for _, partRaw := range contentVal {
partMap, ok := partRaw.(map[string]any)
if !ok {
continue
}
partType, _ := partMap["type"].(string)
switch partType {
case "input_text":
if text, ok := partMap["text"].(string); ok {
textContent += text
}
case "input_image":
if imageURL, ok := partMap["image_url"].(string); ok {
// Convert to base64 data URI
base64, err := utils.GetContentURIAsBase64(imageURL)
if err != nil {
xlog.Error("Failed encoding image", "error", err)
continue
}
stringImages = append(stringImages, base64)
}
case "input_file":
if fileURL, ok := partMap["file_url"].(string); ok {
// Convert to base64
base64, err := utils.GetContentURIAsBase64(fileURL)
if err != nil {
xlog.Error("Failed encoding file", "error", err)
continue
}
// For now, treat files as text content
textContent += base64
} else if fileData, ok := partMap["file_data"].(string); ok {
// Already base64
textContent += fileData
}
case "input_video":
if videoURL, ok := partMap["video_url"].(string); ok {
// Convert to base64 data URI
base64, err := utils.GetContentURIAsBase64(videoURL)
if err != nil {
xlog.Error("Failed encoding video", "error", err)
continue
}
stringVideos = append(stringVideos, base64)
}
case "input_audio":
if audioURL, ok := partMap["audio_url"].(string); ok {
// Convert to base64 data URI
base64, err := utils.GetContentURIAsBase64(audioURL)
if err != nil {
xlog.Error("Failed encoding audio", "error", err)
continue
}
stringAudios = append(stringAudios, base64)
}
}
}
msg.StringContent = textContent
msg.Content = textContent
msg.StringImages = stringImages
msg.StringVideos = stringVideos
msg.StringAudios = stringAudios
// Template multimodal content. Skipped when the backend handles templating
// itself (UseTokenizerTemplate) — it also injects markers server-side and
// StringContent is not consumed by the evaluator in that path.
if (len(stringImages) > 0 || len(stringVideos) > 0 || len(stringAudios) > 0) && !cfg.TemplateConfig.UseTokenizerTemplate {
msg.StringContent, _ = templates.TemplateMultiModal(cfg.TemplateConfig.Multimodal, templates.MultiModalOptions{
TotalImages: len(stringImages),
TotalVideos: len(stringVideos),
TotalAudios: len(stringAudios),
ImagesInMessage: len(stringImages),
VideosInMessage: len(stringVideos),
AudiosInMessage: len(stringAudios),
}, textContent)
}
}
return msg, nil
}
// convertORToolsToFunctions converts Open Responses tools to internal Functions
func convertORToolsToFunctions(input *schema.OpenResponsesRequest, cfg *config.ModelConfig) (functions.Functions, bool) {
if len(input.Tools) == 0 {
return nil, false
}
// Build allowed tools set if specified
allowedSet := make(map[string]bool)
if len(input.AllowedTools) > 0 {
for _, name := range input.AllowedTools {
allowedSet[name] = true
}
}
var funcs functions.Functions
for _, tool := range input.Tools {
if tool.Type == "function" {
// Skip if not in allowed list (when allowed_tools is specified)
if len(allowedSet) > 0 && !allowedSet[tool.Name] {
continue
}
f := functions.Function{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
}
funcs = append(funcs, f)
}
}
// Handle tool_choice
if input.ToolChoice != nil {
switch tc := input.ToolChoice.(type) {
case string:
switch tc {
case "required":
cfg.SetFunctionCallString("required")
case "none":
return nil, false
case "auto":
// "auto" is the default - let model decide whether to use tools
// Tools are available but not forced
}
case map[string]any:
if tcType, ok := tc["type"].(string); ok && tcType == "function" {
if name, ok := tc["name"].(string); ok {
cfg.SetFunctionCallNameString(name)
}
}
}
}
return funcs, len(funcs) > 0 && cfg.ShouldUseFunctions()
}
// convertTextFormatToResponseFormat converts Open Responses text_format to OpenAI response_format
func convertTextFormatToResponseFormat(textFormat any) any {
switch tf := textFormat.(type) {
case map[string]any:
if tfType, ok := tf["type"].(string); ok {
if tfType == "json_schema" {
return map[string]any{
"type": "json_schema",
"json_schema": tf,
}
}
return map[string]any{"type": tfType}
}
case string:
return map[string]any{"type": tf}
}
return nil
}
// handleBackgroundNonStream handles background non-streaming responses
func handleBackgroundNonStream(ctx context.Context, store *ResponseStore, responseID string, createdAt int64, input *schema.OpenResponsesRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool, mcpExecutor mcpTools.ToolExecutor, evaluator *templates.Evaluator) (*schema.ORResponseResource, error) {
mcpMaxIterations := 10
if cfg.Agent.MaxIterations > 0 {
mcpMaxIterations = cfg.Agent.MaxIterations
}
hasMCPTools := mcpExecutor != nil && mcpExecutor.HasTools()
var allOutputItems []schema.ORItemField
for mcpIteration := 0; mcpIteration <= mcpMaxIterations; mcpIteration++ {
if mcpIteration > 0 {
predInput = evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, funcs, shouldUseFn)
xlog.Debug("Background MCP re-templating", "iteration", mcpIteration)
}
// Populate openAIReq fields for ComputeChoices
openAIReq.Tools = convertORToolsToOpenAIFormat(input.Tools)
openAIReq.ToolsChoice = input.ToolChoice
if input.TopLogprobs != nil && *input.TopLogprobs > 0 {
openAIReq.TopLogprobs = input.TopLogprobs
openAIReq.Logprobs = schema.LogprobsValue{Enabled: true}
}
openAIReq.LogitBias = input.LogitBias
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
var result string
cb := func(s string, c *[]schema.Choice) {
result = s
}
choices, tokenUsage, chatDeltas, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, cb, nil)
if err != nil {
return nil, fmt.Errorf("model inference failed: %w", err)
}
// Extract logprobs from choices if available
var resultLogprobs *schema.Logprobs
if len(choices) > 0 {
resultLogprobs = choices[0].Logprobs
}
// Parse tool calls
var funcCallResults []functions.FuncCallResults
var textContent string
if shouldUseFn {
if deltaToolCalls := functions.ToolCallsFromChatDeltas(chatDeltas); len(deltaToolCalls) > 0 {
funcCallResults = deltaToolCalls
textContent = functions.ContentFromChatDeltas(chatDeltas)
} else {
cleanedResult := functions.CleanupLLMResult(result, cfg.FunctionsConfig)
funcCallResults = functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig)
textContent = functions.ParseTextContent(cleanedResult, cfg.FunctionsConfig)
}
noActionName := "answer"
if cfg.FunctionsConfig.NoActionFunctionName != "" {
noActionName = cfg.FunctionsConfig.NoActionFunctionName
}
var toolCalls []schema.ToolCall
for i, fc := range funcCallResults {
if fc.Name == noActionName {
if fc.Arguments != "" {
var args map[string]any
if err := json.Unmarshal([]byte(fc.Arguments), &args); err == nil {
if msg, ok := args["message"].(string); ok && msg != "" {
textContent = msg
}
}
}
continue
}
toolCalls = append(toolCalls, schema.ToolCall{
Index: i,
ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Type: "function",
FunctionCall: schema.FunctionCall{
Name: fc.Name,
Arguments: fc.Arguments,
},
})
}
// MCP tool execution
if hasMCPTools && len(toolCalls) > 0 {
var hasMCPCalls bool
for _, tc := range toolCalls {
if mcpExecutor != nil && mcpExecutor.IsTool(tc.FunctionCall.Name) {
hasMCPCalls = true
break
}
}
if hasMCPCalls {
assistantMsg := schema.Message{Role: "assistant", Content: result, ToolCalls: toolCalls}
openAIReq.Messages = append(openAIReq.Messages, assistantMsg)
for _, tc := range toolCalls {
// Emit function_call + function_call_output items
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "function_call", ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Status: "completed", CallID: tc.ID, Name: tc.FunctionCall.Name, Arguments: tc.FunctionCall.Arguments,
})
if mcpExecutor == nil || !mcpExecutor.IsTool(tc.FunctionCall.Name) {
continue
}
toolResult, toolErr := mcpExecutor.ExecuteTool(ctx, tc.FunctionCall.Name, tc.FunctionCall.Arguments)
if toolErr != nil {
toolResult = fmt.Sprintf("Error: %v", toolErr)
}
openAIReq.Messages = append(openAIReq.Messages, schema.Message{
Role: "tool", Content: toolResult, StringContent: toolResult, ToolCallID: tc.ID, Name: tc.FunctionCall.Name,
})
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "function_call_output", ID: fmt.Sprintf("fco_%s", uuid.New().String()),
Status: "completed", CallID: tc.ID, Output: toolResult,
})
}
continue // next MCP iteration
}
}
// No MCP calls, build output items
if textContent != "" {
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "message", ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed", Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(textContent, resultLogprobs)},
})
}
for _, tc := range toolCalls {
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "function_call", ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Status: "completed", CallID: tc.ID, Name: tc.FunctionCall.Name, Arguments: tc.FunctionCall.Arguments,
})
}
if len(allOutputItems) == 0 && result != "" {
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "message", ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed", Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(result, resultLogprobs)},
})
}
} else if !shouldUseFn && cfg.FunctionsConfig.AutomaticToolParsingFallback && result != "" {
// Automatic tool parsing fallback: no tools in request but model emitted tool call markup
parsed := functions.ParseFunctionCall(result, cfg.FunctionsConfig)
if len(parsed) > 0 {
stripped := functions.StripToolCallMarkup(result)
if stripped != "" {
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "message", ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed", Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(stripped, resultLogprobs)},
})
}
for _, fc := range parsed {
toolCallID := fc.ID
if toolCallID == "" {
toolCallID = fmt.Sprintf("fc_%s", uuid.New().String())
}
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "function_call", ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Status: "completed", CallID: toolCallID, Name: fc.Name, Arguments: fc.Arguments,
})
}
} else {
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "message", ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed", Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(result, resultLogprobs)},
})
}
} else {
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "message", ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed", Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(result, resultLogprobs)},
})
}
now := time.Now().Unix()
return buildORResponse(responseID, createdAt, &now, schema.ORStatusCompleted, input, allOutputItems, &schema.ORUsage{
InputTokens: tokenUsage.Prompt,
OutputTokens: tokenUsage.Completion,
TotalTokens: tokenUsage.Prompt + tokenUsage.Completion,
}, true), nil
} // end MCP iteration loop
return nil, fmt.Errorf("MCP iteration limit reached")
}
// handleBackgroundStream handles background streaming responses with event buffering
func handleBackgroundStream(ctx context.Context, store *ResponseStore, responseID string, createdAt int64, input *schema.OpenResponsesRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool, shouldStore bool, mcpExecutor mcpTools.ToolExecutor, evaluator *templates.Evaluator) (*schema.ORResponseResource, error) {
// Populate openAIReq fields for ComputeChoices
openAIReq.Tools = convertORToolsToOpenAIFormat(input.Tools)
openAIReq.ToolsChoice = input.ToolChoice
if input.TopLogprobs != nil && *input.TopLogprobs > 0 {
openAIReq.TopLogprobs = input.TopLogprobs
openAIReq.Logprobs = schema.LogprobsValue{Enabled: true}
}
openAIReq.LogitBias = input.LogitBias
sequenceNumber := 0
// Emit response.created
responseCreated := buildORResponse(responseID, createdAt, nil, schema.ORStatusInProgress, input, []schema.ORItemField{}, nil, shouldStore)
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.created",
SequenceNumber: sequenceNumber,
Response: responseCreated,
})
sequenceNumber++
// Emit response.in_progress
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.in_progress",
SequenceNumber: sequenceNumber,
Response: responseCreated,
})
sequenceNumber++
var accumulatedText string
var collectedOutputItems []schema.ORItemField
outputIndex := 0
mcpBgStreamMaxIterations := 10
if cfg.Agent.MaxIterations > 0 {
mcpBgStreamMaxIterations = cfg.Agent.MaxIterations
}
hasMCPTools := mcpExecutor != nil && mcpExecutor.HasTools()
var lastTokenUsage backend.TokenUsage
var lastLogprobs *schema.Logprobs
for mcpIter := 0; mcpIter <= mcpBgStreamMaxIterations; mcpIter++ {
if mcpIter > 0 {
predInput = evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, funcs, shouldUseFn)
xlog.Debug("Background stream MCP re-templating", "iteration", mcpIter)
}
accumulatedText = ""
currentMessageID := fmt.Sprintf("msg_%s", uuid.New().String())
// Emit output_item.added
messageItem := &schema.ORItemField{
Type: "message",
ID: currentMessageID,
Status: "in_progress",
Role: "assistant",
Content: []schema.ORContentPart{},
}
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_item.added",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: messageItem,
})
sequenceNumber++
// Emit content_part.added
currentContentIndex := 0
emptyPart := makeOutputTextPart("")
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.content_part.added",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex,
Part: &emptyPart,
})
sequenceNumber++
// Token callback for streaming
tokenCallback := func(token string, tokenUsage backend.TokenUsage) bool {
select {
case <-ctx.Done():
return false
default:
}
accumulatedText += token
// Buffer text delta
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_text.delta",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex,
Delta: strPtr(token),
Logprobs: emptyLogprobs(),
})
sequenceNumber++
return true
}
var result string
cb := func(s string, c *[]schema.Choice) {
result = s
}
choices, tokenUsage, chatDeltas, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, cb, tokenCallback)
if err != nil {
return nil, fmt.Errorf("model inference failed: %w", err)
}
lastTokenUsage = tokenUsage
if len(choices) > 0 {
lastLogprobs = choices[0].Logprobs
}
// Check for MCP tool calls in the streamed result
if shouldUseFn && hasMCPTools {
var funcCallResults []functions.FuncCallResults
if deltaToolCalls := functions.ToolCallsFromChatDeltas(chatDeltas); len(deltaToolCalls) > 0 {
funcCallResults = deltaToolCalls
} else {
cleanedResult := functions.CleanupLLMResult(result, cfg.FunctionsConfig)
funcCallResults = functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig)
}
noActionName := "answer"
if cfg.FunctionsConfig.NoActionFunctionName != "" {
noActionName = cfg.FunctionsConfig.NoActionFunctionName
}
var toolCalls []schema.ToolCall
for i, fc := range funcCallResults {
if fc.Name == noActionName {
continue
}
toolCalls = append(toolCalls, schema.ToolCall{
Index: i, ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Type: "function",
FunctionCall: schema.FunctionCall{Name: fc.Name, Arguments: fc.Arguments},
})
}
var hasMCPCalls bool
for _, tc := range toolCalls {
if mcpExecutor != nil && mcpExecutor.IsTool(tc.FunctionCall.Name) {
hasMCPCalls = true
break
}
}
if hasMCPCalls {
// Close the current message
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_text.done", SequenceNumber: sequenceNumber,
ItemID: currentMessageID, OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex, Text: strPtr(accumulatedText),
Logprobs: emptyLogprobs(),
})
sequenceNumber++
textPart := makeOutputTextPart(accumulatedText)
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.content_part.done", SequenceNumber: sequenceNumber,
ItemID: currentMessageID, OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex, Part: &textPart,
})
sequenceNumber++
completedMsg := &schema.ORItemField{
Type: "message", ID: currentMessageID, Status: "completed",
Role: "assistant", Content: []schema.ORContentPart{textPart},
}
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_item.done", SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex, Item: completedMsg,
})
sequenceNumber++
collectedOutputItems = append(collectedOutputItems, *completedMsg)
// Append assistant message with tool calls
assistantMsg := schema.Message{Role: "assistant", Content: result, ToolCalls: toolCalls}
openAIReq.Messages = append(openAIReq.Messages, assistantMsg)
// Execute MCP tools and emit events
for _, tc := range toolCalls {
outputIndex++
functionCallItem := &schema.ORItemField{
Type: "function_call", ID: tc.ID, Status: "completed",
CallID: tc.ID, Name: tc.FunctionCall.Name, Arguments: tc.FunctionCall.Arguments,
}
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_item.added", SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex, Item: functionCallItem,
})
sequenceNumber++
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_item.done", SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex, Item: functionCallItem,
})
sequenceNumber++
collectedOutputItems = append(collectedOutputItems, *functionCallItem)
if mcpExecutor == nil || !mcpExecutor.IsTool(tc.FunctionCall.Name) {
continue
}
xlog.Debug("Executing MCP tool (background stream)", "tool", tc.FunctionCall.Name, "iteration", mcpIter)
toolResult, toolErr := mcpExecutor.ExecuteTool(ctx, tc.FunctionCall.Name, tc.FunctionCall.Arguments)
if toolErr != nil {
toolResult = fmt.Sprintf("Error: %v", toolErr)
}
openAIReq.Messages = append(openAIReq.Messages, schema.Message{
Role: "tool", Content: toolResult, StringContent: toolResult, ToolCallID: tc.ID, Name: tc.FunctionCall.Name,
})
outputIndex++
outputItem := &schema.ORItemField{
Type: "function_call_output", ID: fmt.Sprintf("fco_%s", uuid.New().String()),
Status: "completed", CallID: tc.ID, Output: toolResult,
}
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_item.added", SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex, Item: outputItem,
})
sequenceNumber++
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_item.done", SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex, Item: outputItem,
})
sequenceNumber++
collectedOutputItems = append(collectedOutputItems, *outputItem)
}
continue // next MCP iteration
}
}
// No MCP tools — close the message and break
streamEventLogprobs := convertLogprobsForStreaming(lastLogprobs)
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_text.done",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex,
Text: strPtr(accumulatedText),
Logprobs: logprobsPtr(streamEventLogprobs),
})
sequenceNumber++
textPart := makeOutputTextPartWithLogprobs(accumulatedText, lastLogprobs)
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.content_part.done",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex,
Part: &textPart,
})
sequenceNumber++
completedMessageItem := &schema.ORItemField{
Type: "message",
ID: currentMessageID,
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(accumulatedText, lastLogprobs)},
}
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.output_item.done",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: completedMessageItem,
})
sequenceNumber++
collectedOutputItems = append(collectedOutputItems, *completedMessageItem)
break
} // end MCP background stream iteration loop
// Build final response
now := time.Now().Unix()
response := buildORResponse(responseID, createdAt, &now, schema.ORStatusCompleted, input, collectedOutputItems, &schema.ORUsage{
InputTokens: lastTokenUsage.Prompt,
OutputTokens: lastTokenUsage.Completion,
TotalTokens: lastTokenUsage.Prompt + lastTokenUsage.Completion,
}, shouldStore)
// Emit response.completed
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.completed",
SequenceNumber: sequenceNumber,
Response: response,
})
return response, nil
}
// bufferEvent stores an SSE event in the response store for streaming resume
func bufferEvent(store *ResponseStore, responseID string, event *schema.ORStreamEvent) {
normalizeORStreamEvent(event)
if err := store.AppendEvent(responseID, event); err != nil {
xlog.Error("Failed to buffer event", "response_id", responseID, "error", err)
}
}
// handleOpenResponsesNonStream handles non-streaming responses
func handleOpenResponsesNonStream(c echo.Context, responseID string, createdAt int64, input *schema.OpenResponsesRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool, shouldStore bool, mcpExecutor mcpTools.ToolExecutor, evaluator *templates.Evaluator, mcpIteration int) error {
mcpMaxIterations := 10
if cfg.Agent.MaxIterations > 0 {
mcpMaxIterations = cfg.Agent.MaxIterations
}
if mcpIteration > mcpMaxIterations {
return sendOpenResponsesError(c, 500, "server_error", "MCP iteration limit reached", "")
}
// Populate openAIReq fields for ComputeChoices
openAIReq.Tools = convertORToolsToOpenAIFormat(input.Tools)
openAIReq.ToolsChoice = input.ToolChoice
if input.TopLogprobs != nil && *input.TopLogprobs > 0 {
openAIReq.TopLogprobs = input.TopLogprobs
openAIReq.Logprobs = schema.LogprobsValue{Enabled: true}
}
openAIReq.LogitBias = input.LogitBias
var result string
cb := func(s string, c *[]schema.Choice) {
result = s
}
choices, tokenUsage, chatDeltas, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, cb, nil)
if err != nil {
xlog.Error("Open Responses model inference failed", "error", err)
return sendOpenResponsesError(c, 500, "model_error", fmt.Sprintf("model inference failed: %v", err), "")
}
var resultLogprobs *schema.Logprobs
if len(choices) > 0 {
resultLogprobs = choices[0].Logprobs
}
xlog.Debug("Open Responses - Raw model result", "result", result, "shouldUseFn", shouldUseFn)
// Detect if thinking token is already in prompt or template
var template string
if cfg.TemplateConfig.UseTokenizerTemplate {
template = cfg.GetModelTemplate()
} else {
template = predInput
}
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
if cfg.TemplateConfig.UseTokenizerTemplate {
thinkingStartToken = reason.DetectThinkingStartTokenInTemplate(template, &cfg.ReasoningConfig)
}
// Extract reasoning from result before cleaning
reasoningContent, cleanedResult := reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
// Parse tool calls if using functions
var outputItems []schema.ORItemField
var toolCalls []schema.ToolCall
// Add reasoning item if reasoning was found (reasoning comes first per spec)
if reasoningContent != "" {
reasoningItem := schema.ORItemField{
Type: "reasoning",
ID: fmt.Sprintf("reasoning_%s", uuid.New().String()),
Status: "completed",
Content: []schema.ORContentPart{makeOutputTextPart(reasoningContent)},
}
outputItems = append(outputItems, reasoningItem)
xlog.Debug("Open Responses - Extracted reasoning", "reasoning_length", len(reasoningContent))
}
if shouldUseFn {
var funcCallResults []functions.FuncCallResults
var textContent string
// Try pre-parsed tool calls from C++ autoparser first
if deltaToolCalls := functions.ToolCallsFromChatDeltas(chatDeltas); len(deltaToolCalls) > 0 {
xlog.Debug("[ChatDeltas] OpenResponses: using pre-parsed tool calls", "count", len(deltaToolCalls))
funcCallResults = deltaToolCalls
textContent = functions.ContentFromChatDeltas(chatDeltas)
} else {
xlog.Debug("[ChatDeltas] OpenResponses: no pre-parsed tool calls, falling back to Go-side text parsing")
// Clean up the result (already extracted reasoning above)
cleanedResult = functions.CleanupLLMResult(cleanedResult, cfg.FunctionsConfig)
funcCallResults = functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig)
textContent = functions.ParseTextContent(cleanedResult, cfg.FunctionsConfig)
}
xlog.Debug("[ChatDeltas] OpenResponses: final tool call decision", "count", len(funcCallResults), "textContent", textContent)
// Check for noAction function (model chose to respond without tool)
noActionName := "answer"
if cfg.FunctionsConfig.NoActionFunctionName != "" {
noActionName = cfg.FunctionsConfig.NoActionFunctionName
}
// Filter out noAction calls and extract the message
for i, fc := range funcCallResults {
if fc.Name == noActionName {
// This is a text response, not a tool call
// Try to extract the message from the arguments
if fc.Arguments != "" {
var args map[string]any
if err := json.Unmarshal([]byte(fc.Arguments), &args); err == nil {
if msg, ok := args["message"].(string); ok && msg != "" {
textContent = msg
}
}
}
continue
}
toolCalls = append(toolCalls, schema.ToolCall{
Index: i,
ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Type: "function",
FunctionCall: schema.FunctionCall{
Name: fc.Name,
Arguments: fc.Arguments,
},
})
}
// MCP server-side tool execution: if any tool calls are MCP tools, execute and re-run
if mcpExecutor != nil && mcpExecutor.HasTools() && len(toolCalls) > 0 {
var hasMCPCalls bool
for _, tc := range toolCalls {
if mcpExecutor != nil && mcpExecutor.IsTool(tc.FunctionCall.Name) {
hasMCPCalls = true
break
}
}
if hasMCPCalls {
// Append assistant message with tool_calls to conversation
assistantMsg := schema.Message{Role: "assistant", Content: result, ToolCalls: toolCalls}
openAIReq.Messages = append(openAIReq.Messages, assistantMsg)
// Execute each MCP tool call and append results
for _, tc := range toolCalls {
if mcpExecutor == nil || !mcpExecutor.IsTool(tc.FunctionCall.Name) {
continue
}
xlog.Debug("Executing MCP tool (Open Responses)", "tool", tc.FunctionCall.Name)
toolResult, toolErr := mcpExecutor.ExecuteTool(
c.Request().Context(), tc.FunctionCall.Name, tc.FunctionCall.Arguments,
)
if toolErr != nil {
xlog.Error("MCP tool execution failed", "tool", tc.FunctionCall.Name, "error", toolErr)
toolResult = fmt.Sprintf("Error: %v", toolErr)
}
openAIReq.Messages = append(openAIReq.Messages, schema.Message{
Role: "tool", Content: toolResult, StringContent: toolResult, ToolCallID: tc.ID, Name: tc.FunctionCall.Name,
})
// Collect function_call + function_call_output items for the response
outputItems = append(outputItems, schema.ORItemField{
Type: "function_call", ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Status: "completed", CallID: tc.ID, Name: tc.FunctionCall.Name, Arguments: tc.FunctionCall.Arguments,
})
outputItems = append(outputItems, schema.ORItemField{
Type: "function_call_output", ID: fmt.Sprintf("fco_%s", uuid.New().String()),
Status: "completed", CallID: tc.ID, Output: toolResult,
})
}
// Re-template and re-run inference
predInput = evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, funcs, shouldUseFn)
return handleOpenResponsesNonStream(c, responseID, createdAt, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, shouldStore, mcpExecutor, evaluator, mcpIteration+1)
}
}
// Add message item with text content (include logprobs if available)
if textContent != "" {
outputItems = append(outputItems, schema.ORItemField{
Type: "message",
ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(textContent, resultLogprobs)},
})
}
// Add function call items
for _, tc := range toolCalls {
outputItems = append(outputItems, schema.ORItemField{
Type: "function_call",
ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Status: "completed",
CallID: tc.ID,
Name: tc.FunctionCall.Name,
Arguments: tc.FunctionCall.Arguments,
})
}
// If we have no output items but the model did produce output, include the cleaned result as a message
hasMessageItem := false
for _, item := range outputItems {
if item.Type == "message" {
hasMessageItem = true
break
}
}
if !hasMessageItem && cleanedResult != "" {
xlog.Debug("Open Responses - No parsed output, falling back to cleaned result")
outputItems = append(outputItems, schema.ORItemField{
Type: "message",
ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(cleanedResult, resultLogprobs)},
})
}
} else if !shouldUseFn && cfg.FunctionsConfig.AutomaticToolParsingFallback && cleanedResult != "" {
// Automatic tool parsing fallback: no tools in request but model emitted tool call markup
parsed := functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig)
if len(parsed) > 0 {
stripped := functions.StripToolCallMarkup(cleanedResult)
if stripped != "" {
outputItems = append(outputItems, schema.ORItemField{
Type: "message",
ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(stripped, resultLogprobs)},
})
}
for _, fc := range parsed {
toolCallID := fc.ID
if toolCallID == "" {
toolCallID = fmt.Sprintf("fc_%s", uuid.New().String())
}
outputItems = append(outputItems, schema.ORItemField{
Type: "function_call",
ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Status: "completed",
CallID: toolCallID,
Name: fc.Name,
Arguments: fc.Arguments,
})
}
} else {
outputItems = append(outputItems, schema.ORItemField{
Type: "message",
ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(cleanedResult, resultLogprobs)},
})
}
} else {
// Simple text response (include logprobs if available)
messageItem := schema.ORItemField{
Type: "message",
ID: fmt.Sprintf("msg_%s", uuid.New().String()),
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(cleanedResult, resultLogprobs)},
}
outputItems = append(outputItems, messageItem)
}
// Calculate reasoning tokens (approximate: character count / 4)
reasoningTokens := 0
if reasoningContent != "" {
// Simple estimation: ~4 characters per token
reasoningTokens = len(reasoningContent) / 4
if reasoningTokens == 0 && len(reasoningContent) > 0 {
reasoningTokens = 1
}
}
// Build response with all required fields
now := time.Now().Unix()
response := buildORResponse(responseID, createdAt, &now, "completed", input, outputItems, &schema.ORUsage{
InputTokens: tokenUsage.Prompt,
OutputTokens: tokenUsage.Completion,
TotalTokens: tokenUsage.Prompt + tokenUsage.Completion,
OutputTokensDetails: &schema.OROutputTokensDetails{
ReasoningTokens: reasoningTokens,
},
}, shouldStore)
// Store response for future reference (if enabled)
if shouldStore {
store := GetGlobalStore()
store.StoreOwned(responseID, input, response, ownerFromContext(c))
}
return c.JSON(200, response)
}
// handleOpenResponsesStream handles streaming responses
func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int64, input *schema.OpenResponsesRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool, shouldStore bool, mcpExecutor mcpTools.ToolExecutor, evaluator *templates.Evaluator) error {
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
sequenceNumber := 0
// Emit response.created - use helper to create response with all required fields
responseCreated := buildORResponse(responseID, createdAt, nil, "in_progress", input, []schema.ORItemField{}, nil, shouldStore)
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.created",
SequenceNumber: sequenceNumber,
Response: responseCreated,
})
sequenceNumber++
// Emit response.in_progress
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.in_progress",
SequenceNumber: sequenceNumber,
Response: responseCreated,
})
sequenceNumber++
// Populate openAIReq fields for ComputeChoices
openAIReq.Tools = convertORToolsToOpenAIFormat(input.Tools)
openAIReq.ToolsChoice = input.ToolChoice
if input.TopLogprobs != nil && *input.TopLogprobs > 0 {
openAIReq.TopLogprobs = input.TopLogprobs
openAIReq.Logprobs = schema.LogprobsValue{Enabled: true}
}
openAIReq.LogitBias = input.LogitBias
// Detect if thinking token is already in prompt or template
var template string
if cfg.TemplateConfig.UseTokenizerTemplate {
template = cfg.GetModelTemplate()
} else {
template = predInput
}
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
if cfg.TemplateConfig.UseTokenizerTemplate {
thinkingStartToken = reason.DetectThinkingStartTokenInTemplate(template, &cfg.ReasoningConfig)
}
// Track state for streaming
var currentMessageID string
var currentContentIndex int
var accumulatedText string
var lastEmittedToolCallCount int
outputIndex := 0
inToolCallMode := false
// Track reasoning state for streaming
var currentReasoningID string
var currentReasoningContentIndex int
var reasoningTokens int
extractor := reason.NewReasoningExtractor(thinkingStartToken, cfg.ReasoningConfig)
// Collect all output items for storage
var collectedOutputItems []schema.ORItemField
if shouldUseFn {
mcpStreamMaxIterations := 10
if cfg.Agent.MaxIterations > 0 {
mcpStreamMaxIterations = cfg.Agent.MaxIterations
}
hasMCPToolsStream := mcpExecutor != nil && mcpExecutor.HasTools()
var result, finalReasoning, finalCleanedResult string
var textContent string
var parsedToolCalls []functions.FuncCallResults
var toolCalls []functions.FuncCallResults
var lastStreamTokenUsage backend.TokenUsage
var lastStreamLogprobs *schema.Logprobs
for mcpStreamIter := 0; mcpStreamIter <= mcpStreamMaxIterations; mcpStreamIter++ {
if mcpStreamIter > 0 {
// Reset reasoning and tool-call state for re-inference so reasoning
// extraction runs again on subsequent iterations
inToolCallMode = false
extractor.Reset()
currentMessageID = ""
lastEmittedToolCallCount = 0
currentReasoningID = ""
predInput = evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, funcs, shouldUseFn)
xlog.Debug("Open Responses stream MCP re-templating", "iteration", mcpStreamIter)
}
// For tool calls, we need to track accumulated result and parse incrementally
// We'll handle this differently - track the full result and parse tool calls
accumulatedResult := ""
tokenCallback := func(token string, tokenUsage backend.TokenUsage) bool {
accumulatedResult += token
accumulatedText += token
// Try to parse tool calls incrementally
cleanedResult := functions.CleanupLLMResult(accumulatedResult, cfg.FunctionsConfig)
// Determine XML format from config
var xmlFormat *functions.XMLToolCallFormat
if cfg.FunctionsConfig.XMLFormat != nil {
xmlFormat = cfg.FunctionsConfig.XMLFormat
} else if cfg.FunctionsConfig.XMLFormatPreset != "" {
xmlFormat = functions.GetXMLFormatPreset(cfg.FunctionsConfig.XMLFormatPreset)
}
// Try XML parsing first
partialResults, parseErr := functions.ParseXMLIterative(cleanedResult, xmlFormat, true)
if parseErr == nil && len(partialResults) > lastEmittedToolCallCount {
// New tool calls detected
if !inToolCallMode && currentMessageID != "" {
// Close the current message content part
textPart := makeOutputTextPart(functions.ParseTextContent(cleanedResult, cfg.FunctionsConfig))
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.content_part.done",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex,
Part: &textPart,
})
sequenceNumber++
inToolCallMode = true
}
// Emit new tool calls
for i := lastEmittedToolCallCount; i < len(partialResults); i++ {
tc := partialResults[i]
toolCallID := fmt.Sprintf("fc_%s", uuid.New().String())
outputIndex++
// Emit function_call item added
functionCallItem := &schema.ORItemField{
Type: "function_call",
ID: toolCallID,
Status: "in_progress",
CallID: toolCallID,
Name: tc.Name,
Arguments: "",
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: functionCallItem,
})
sequenceNumber++
// Emit arguments delta
if tc.Arguments != "" {
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.function_call_arguments.delta",
SequenceNumber: sequenceNumber,
ItemID: toolCallID,
OutputIndex: &outputIndex,
Delta: strPtr(tc.Arguments),
})
sequenceNumber++
// Emit arguments done
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.function_call_arguments.done",
SequenceNumber: sequenceNumber,
ItemID: toolCallID,
OutputIndex: &outputIndex,
Arguments: strPtr(tc.Arguments),
})
sequenceNumber++
// Emit function_call item done
functionCallItem.Status = "completed"
functionCallItem.Arguments = tc.Arguments
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: functionCallItem,
})
sequenceNumber++
// Collect item for storage
collectedOutputItems = append(collectedOutputItems, *functionCallItem)
}
}
lastEmittedToolCallCount = len(partialResults)
c.Response().Flush()
return true
}
// Only completed JSON calls can be emitted as completed SSE items.
jsonResults := parseStreamingJSONToolCalls(cleanedResult)
if len(jsonResults) > lastEmittedToolCallCount {
for i := lastEmittedToolCallCount; i < len(jsonResults); i++ {
tc := jsonResults[i]
toolCallID := fmt.Sprintf("fc_%s", uuid.New().String())
outputIndex++
functionCallItem := &schema.ORItemField{
Type: "function_call",
ID: toolCallID,
Status: "completed",
CallID: toolCallID,
Name: tc.Name,
Arguments: tc.Arguments,
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: functionCallItem,
})
sequenceNumber++
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: functionCallItem,
})
sequenceNumber++
}
lastEmittedToolCallCount = len(jsonResults)
c.Response().Flush()
return true
}
// If no tool calls detected yet, handle reasoning and text
if !inToolCallMode {
var reasoningDelta, contentDelta string
goReasoning, goContent := extractor.ProcessToken(token)
if tokenUsage.HasChatDeltaContent() {
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
contentDelta = cd
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
} else {
reasoningDelta = goReasoning
contentDelta = goContent
}
// Handle reasoning item
if extractor.Reasoning() != "" {
// Check if we need to create reasoning item
if currentReasoningID == "" {
outputIndex++
currentReasoningID = fmt.Sprintf("reasoning_%s", uuid.New().String())
reasoningItem := &schema.ORItemField{
Type: "reasoning",
ID: currentReasoningID,
Status: "in_progress",
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: reasoningItem,
})
sequenceNumber++
// Emit content_part.added for reasoning
currentReasoningContentIndex = 0
emptyPart := makeOutputTextPart("")
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.content_part.added",
SequenceNumber: sequenceNumber,
ItemID: currentReasoningID,
OutputIndex: &outputIndex,
ContentIndex: &currentReasoningContentIndex,
Part: &emptyPart,
})
sequenceNumber++
}
// Emit reasoning delta if there's new content
if reasoningDelta != "" {
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_text.delta",
SequenceNumber: sequenceNumber,
ItemID: currentReasoningID,
OutputIndex: &outputIndex,
ContentIndex: &currentReasoningContentIndex,
Delta: strPtr(reasoningDelta),
Logprobs: emptyLogprobs(),
})
sequenceNumber++
c.Response().Flush()
}
}
// Only emit message content if there's actual content (not just reasoning)
if contentDelta != "" {
if currentMessageID == "" {
// Emit output_item.added for message
outputIndex++
currentMessageID = fmt.Sprintf("msg_%s", uuid.New().String())
messageItem := &schema.ORItemField{
Type: "message",
ID: currentMessageID,
Status: "in_progress",
Role: "assistant",
Content: []schema.ORContentPart{},
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: messageItem,
})
sequenceNumber++
// Emit content_part.added
currentContentIndex = 0
emptyPart := makeOutputTextPart("")
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.content_part.added",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex,
Part: &emptyPart,
})
sequenceNumber++
}
// Emit text delta
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_text.delta",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex,
Delta: strPtr(contentDelta),
Logprobs: emptyLogprobs(),
})
sequenceNumber++
c.Response().Flush()
}
}
return true
}
var ccResult string
ccCb := func(s string, c *[]schema.Choice) {
ccResult = s
}
choices, ccTokenUsage, chatDeltas, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, ccCb, tokenCallback)
if err != nil {
xlog.Error("Open Responses stream model inference failed", "error", err)
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "error",
SequenceNumber: sequenceNumber,
Error: &schema.ORErrorPayload{
Type: "model_error",
Message: fmt.Sprintf("model inference failed: %v", err),
},
})
sequenceNumber++
responseFailed := responseCreated
responseFailed.Status = "failed"
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.failed",
SequenceNumber: sequenceNumber,
Response: responseFailed,
})
// Send [DONE] even on error
fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
c.Response().Flush()
return nil
}
result = ccResult
lastStreamTokenUsage = ccTokenUsage
if len(choices) > 0 {
lastStreamLogprobs = choices[0].Logprobs
}
// Source reasoning from: (1) ChatDeltas from C++ autoparser, (2) extractor's
// streaming state, (3) final extraction from the finetuned result.
// Issue #9985: when the autoparser delivered Content but no
// ReasoningContent, it was running in the "pure content" PEG fallback
// (non-jinja path) which leaves reasoning tags embedded in content.
// Fall back to the streaming Go-side extractor's split in that case.
if chatDeltaReasoning := functions.ReasoningFromChatDeltas(chatDeltas); chatDeltaReasoning != "" {
finalReasoning = chatDeltaReasoning
finalCleanedResult = functions.ContentFromChatDeltas(chatDeltas)
if finalCleanedResult == "" {
finalCleanedResult = extractor.CleanedContent()
}
} else {
finalReasoning = extractor.Reasoning()
finalCleanedResult = extractor.CleanedContent()
}
if finalReasoning == "" && finalCleanedResult == "" {
finalReasoning, finalCleanedResult = reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
}
// Close reasoning item if it exists and wasn't closed yet
if currentReasoningID != "" && finalReasoning != "" {
// Emit output_text.done for reasoning
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_text.done",
SequenceNumber: sequenceNumber,
ItemID: currentReasoningID,
OutputIndex: &outputIndex,
ContentIndex: &currentReasoningContentIndex,
Text: strPtr(finalReasoning),
Logprobs: emptyLogprobs(),
})
sequenceNumber++
// Emit content_part.done for reasoning
reasoningPart := makeOutputTextPart(finalReasoning)
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.content_part.done",
SequenceNumber: sequenceNumber,
ItemID: currentReasoningID,
OutputIndex: &outputIndex,
ContentIndex: &currentReasoningContentIndex,
Part: &reasoningPart,
})
sequenceNumber++
// Emit output_item.done for reasoning
reasoningItem := &schema.ORItemField{
Type: "reasoning",
ID: currentReasoningID,
Status: "completed",
Content: []schema.ORContentPart{reasoningPart},
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: reasoningItem,
})
sequenceNumber++
// Collect reasoning item for storage
collectedOutputItems = append(collectedOutputItems, *reasoningItem)
// Calculate reasoning tokens
reasoningTokens = len(finalReasoning) / 4
if reasoningTokens == 0 && len(finalReasoning) > 0 {
reasoningTokens = 1
}
}
parsedToolCalls = nil
textContent = ""
// Try pre-parsed tool calls from C++ autoparser first
if deltaToolCalls := functions.ToolCallsFromChatDeltas(chatDeltas); len(deltaToolCalls) > 0 {
xlog.Debug("[ChatDeltas] OpenResponses Stream: using pre-parsed tool calls", "count", len(deltaToolCalls))
parsedToolCalls = deltaToolCalls
textContent = functions.ContentFromChatDeltas(chatDeltas)
} else {
xlog.Debug("[ChatDeltas] OpenResponses Stream: no pre-parsed tool calls, falling back to Go-side text parsing")
cleanedResult := functions.CleanupLLMResult(finalCleanedResult, cfg.FunctionsConfig)
parsedToolCalls = functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig)
textContent = functions.ParseTextContent(cleanedResult, cfg.FunctionsConfig)
}
// Handle noAction function (model chose to respond without tool)
noActionName := "answer"
if cfg.FunctionsConfig.NoActionFunctionName != "" {
noActionName = cfg.FunctionsConfig.NoActionFunctionName
}
// Filter out noAction calls and extract the message
toolCalls = nil
for _, fc := range parsedToolCalls {
if fc.Name == noActionName {
// This is a text response, not a tool call
if fc.Arguments != "" {
var args map[string]any
if err := json.Unmarshal([]byte(fc.Arguments), &args); err == nil {
if msg, ok := args["message"].(string); ok && msg != "" {
textContent = msg
}
}
}
continue
}
toolCalls = append(toolCalls, fc)
}
xlog.Debug("Open Responses Stream - Parsed", "toolCalls", len(toolCalls), "textContent", textContent)
// MCP streaming tool execution: check if any tool calls are MCP tools
if hasMCPToolsStream && len(toolCalls) > 0 {
var hasMCPCalls bool
for _, tc := range toolCalls {
if mcpExecutor != nil && mcpExecutor.IsTool(tc.Name) {
hasMCPCalls = true
break
}
}
if hasMCPCalls {
// Build schema.ToolCall list for the assistant message
var schemaToolCalls []schema.ToolCall
for i, tc := range toolCalls {
schemaToolCalls = append(schemaToolCalls, schema.ToolCall{
Index: i, ID: fmt.Sprintf("fc_%s", uuid.New().String()),
Type: "function",
FunctionCall: schema.FunctionCall{Name: tc.Name, Arguments: tc.Arguments},
})
}
assistantMsg := schema.Message{Role: "assistant", Content: result, ToolCalls: schemaToolCalls}
openAIReq.Messages = append(openAIReq.Messages, assistantMsg)
for idx, tc := range toolCalls {
tcID := schemaToolCalls[idx].ID
// Emit function_call item
outputIndex++
functionCallItem := &schema.ORItemField{
Type: "function_call", ID: tcID, Status: "completed",
CallID: tcID, Name: tc.Name, Arguments: tc.Arguments,
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added", SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex, Item: functionCallItem,
})
sequenceNumber++
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done", SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex, Item: functionCallItem,
})
sequenceNumber++
collectedOutputItems = append(collectedOutputItems, *functionCallItem)
if mcpExecutor == nil || !mcpExecutor.IsTool(tc.Name) {
continue
}
// Execute MCP tool
xlog.Debug("Executing MCP tool (Open Responses stream)", "tool", tc.Name, "iteration", mcpStreamIter)
toolResult, toolErr := mcpExecutor.ExecuteTool(
input.Context, tc.Name, tc.Arguments,
)
if toolErr != nil {
xlog.Error("MCP tool execution failed", "tool", tc.Name, "error", toolErr)
toolResult = fmt.Sprintf("Error: %v", toolErr)
}
openAIReq.Messages = append(openAIReq.Messages, schema.Message{
Role: "tool", Content: toolResult, StringContent: toolResult, ToolCallID: tcID, Name: tc.Name,
})
// Emit function_call_output item
outputIndex++
outputItem := &schema.ORItemField{
Type: "function_call_output", ID: fmt.Sprintf("fco_%s", uuid.New().String()),
Status: "completed", CallID: tcID, Output: toolResult,
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added", SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex, Item: outputItem,
})
sequenceNumber++
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done", SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex, Item: outputItem,
})
sequenceNumber++
collectedOutputItems = append(collectedOutputItems, *outputItem)
}
c.Response().Flush()
xlog.Debug("MCP streaming tools executed, re-running inference", "iteration", mcpStreamIter)
continue // next MCP stream iteration
}
}
// Convert logprobs for streaming events
streamEventLogprobs := convertLogprobsForStreaming(lastStreamLogprobs)
// If we have no output but the model did produce something, use the cleaned result (without reasoning tags)
if textContent == "" && len(toolCalls) == 0 && finalCleanedResult != "" {
xlog.Debug("Open Responses Stream - No parsed output, using cleaned result")
textContent = finalCleanedResult
}
// Close message if we have text content
if currentMessageID != "" && textContent != "" && !inToolCallMode {
// Emit output_text.done
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_text.done",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex,
Text: strPtr(textContent),
Logprobs: logprobsPtr(streamEventLogprobs),
})
sequenceNumber++
// Emit content_part.done (with actual logprobs)
textPart := makeOutputTextPartWithLogprobs(textContent, lastStreamLogprobs)
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.content_part.done",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &outputIndex,
ContentIndex: &currentContentIndex,
Part: &textPart,
})
sequenceNumber++
// Emit output_item.done for message (with actual logprobs)
messageItem := &schema.ORItemField{
Type: "message",
ID: currentMessageID,
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(textContent, lastStreamLogprobs)},
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: messageItem,
})
sequenceNumber++
// Collect message item for storage
collectedOutputItems = append(collectedOutputItems, *messageItem)
}
// Emit any remaining tool calls that weren't streamed
for i := lastEmittedToolCallCount; i < len(toolCalls); i++ {
tc := toolCalls[i]
toolCallID := fmt.Sprintf("fc_%s", uuid.New().String())
outputIndex++
functionCallItem := &schema.ORItemField{
Type: "function_call",
ID: toolCallID,
Status: "completed",
CallID: toolCallID,
Name: tc.Name,
Arguments: tc.Arguments,
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: functionCallItem,
})
sequenceNumber++
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: functionCallItem,
})
sequenceNumber++
// Collect function call item for storage
collectedOutputItems = append(collectedOutputItems, *functionCallItem)
}
break // no MCP tools to execute, exit loop
} // end MCP stream iteration loop
// Build final response with all items (include reasoning first, then messages, then tool calls)
var allOutputItems []schema.ORItemField
// Add reasoning item if it exists
if currentReasoningID != "" && finalReasoning != "" {
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "reasoning",
ID: currentReasoningID,
Status: "completed",
Content: []schema.ORContentPart{makeOutputTextPart(finalReasoning)},
})
}
// Add message item
if currentMessageID != "" && textContent != "" {
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "message",
ID: currentMessageID,
Status: "completed",
Role: "assistant",
Content: []schema.ORContentPart{makeOutputTextPartWithLogprobs(textContent, lastStreamLogprobs)},
})
}
// Add tool call items
for _, tc := range toolCalls {
toolCallID := fmt.Sprintf("fc_%s", uuid.New().String())
allOutputItems = append(allOutputItems, schema.ORItemField{
Type: "function_call",
ID: toolCallID,
Status: "completed",
CallID: toolCallID,
Name: tc.Name,
Arguments: tc.Arguments,
})
}
// Emit response.completed
now := time.Now().Unix()
responseCompleted := buildORResponse(responseID, createdAt, &now, "completed", input, allOutputItems, &schema.ORUsage{
InputTokens: lastStreamTokenUsage.Prompt,
OutputTokens: lastStreamTokenUsage.Completion,
TotalTokens: lastStreamTokenUsage.Prompt + lastStreamTokenUsage.Completion,
OutputTokensDetails: &schema.OROutputTokensDetails{
ReasoningTokens: reasoningTokens,
},
}, shouldStore)
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.completed",
SequenceNumber: sequenceNumber,
Response: responseCompleted,
})
// Store response for future reference (if enabled)
if shouldStore {
store := GetGlobalStore()
store.StoreOwned(responseID, input, responseCompleted, ownerFromContext(c))
}
// Send [DONE]
fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
c.Response().Flush()
return nil
}
// Non-tool-call streaming path
messageOutputIndex := outputIndex
var reasoningOutputIndex int
// Emit output_item.added for message
currentMessageID = fmt.Sprintf("msg_%s", uuid.New().String())
messageItem := &schema.ORItemField{
Type: "message",
ID: currentMessageID,
Status: "in_progress",
Role: "assistant",
Content: []schema.ORContentPart{},
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added",
SequenceNumber: sequenceNumber,
OutputIndex: &messageOutputIndex,
Item: messageItem,
})
sequenceNumber++
// Emit content_part.added
currentContentIndex = 0
emptyTextPart := makeOutputTextPart("")
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.content_part.added",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &messageOutputIndex,
ContentIndex: &currentContentIndex,
Part: &emptyTextPart,
})
sequenceNumber++
// Stream text deltas with reasoning extraction
tokenCallback := func(token string, tokenUsage backend.TokenUsage) bool {
accumulatedText += token
var reasoningDelta, contentDelta string
goReasoning, goContent := extractor.ProcessToken(token)
if tokenUsage.HasChatDeltaContent() {
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
contentDelta = cd
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
} else {
reasoningDelta = goReasoning
contentDelta = goContent
}
// Handle reasoning item
if extractor.Reasoning() != "" || reasoningDelta != "" {
// Check if we need to create reasoning item
if currentReasoningID == "" {
outputIndex++
reasoningOutputIndex = outputIndex
currentReasoningID = fmt.Sprintf("reasoning_%s", uuid.New().String())
reasoningItem := &schema.ORItemField{
Type: "reasoning",
ID: currentReasoningID,
Status: "in_progress",
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added",
SequenceNumber: sequenceNumber,
OutputIndex: &reasoningOutputIndex,
Item: reasoningItem,
})
sequenceNumber++
// Emit content_part.added for reasoning
currentReasoningContentIndex = 0
emptyPart := makeOutputTextPart("")
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.content_part.added",
SequenceNumber: sequenceNumber,
ItemID: currentReasoningID,
OutputIndex: &reasoningOutputIndex,
ContentIndex: &currentReasoningContentIndex,
Part: &emptyPart,
})
sequenceNumber++
}
// Emit reasoning delta if there's new content
if reasoningDelta != "" {
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_text.delta",
SequenceNumber: sequenceNumber,
ItemID: currentReasoningID,
OutputIndex: &reasoningOutputIndex,
ContentIndex: &currentReasoningContentIndex,
Delta: strPtr(reasoningDelta),
Logprobs: emptyLogprobs(),
})
sequenceNumber++
c.Response().Flush()
}
}
// Only emit message content if there's actual content (not just reasoning)
if contentDelta != "" {
// Emit text delta
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_text.delta",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &messageOutputIndex,
ContentIndex: &currentContentIndex,
Delta: strPtr(contentDelta),
Logprobs: emptyLogprobs(),
})
sequenceNumber++
c.Response().Flush()
}
return true
}
var noToolResult string
noToolCb := func(s string, c *[]schema.Choice) {
noToolResult = s
}
noToolChoices, noToolTokenUsage, noToolChatDeltas, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, noToolCb, tokenCallback)
if err != nil {
xlog.Error("Open Responses stream model inference failed", "error", err)
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "error",
SequenceNumber: sequenceNumber,
Error: &schema.ORErrorPayload{
Type: "model_error",
Message: fmt.Sprintf("model inference failed: %v", err),
},
})
sequenceNumber++
responseFailed := responseCreated
responseFailed.Status = "failed"
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.failed",
SequenceNumber: sequenceNumber,
Response: responseFailed,
})
// Send [DONE] even on error
fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
c.Response().Flush()
return nil
}
result := noToolResult
var noToolLogprobs *schema.Logprobs
if len(noToolChoices) > 0 {
noToolLogprobs = noToolChoices[0].Logprobs
}
// Source reasoning from: (1) ChatDeltas from C++ autoparser, (2) extractor's
// streaming state, (3) final extraction from the finetuned result.
var finalReasoning, finalCleanedResult string
if chatDeltaReasoning := functions.ReasoningFromChatDeltas(noToolChatDeltas); chatDeltaReasoning != "" {
finalReasoning = chatDeltaReasoning
finalCleanedResult = functions.ContentFromChatDeltas(noToolChatDeltas)
if finalCleanedResult == "" {
finalCleanedResult = extractor.CleanedContent()
}
} else {
finalReasoning = extractor.Reasoning()
finalCleanedResult = extractor.CleanedContent()
}
if finalReasoning == "" && finalCleanedResult == "" {
finalReasoning, finalCleanedResult = reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
}
// Close reasoning item if it exists and wasn't closed yet
if currentReasoningID != "" && finalReasoning != "" {
// Emit output_text.done for reasoning
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_text.done",
SequenceNumber: sequenceNumber,
ItemID: currentReasoningID,
OutputIndex: &reasoningOutputIndex,
ContentIndex: &currentReasoningContentIndex,
Text: strPtr(finalReasoning),
Logprobs: emptyLogprobs(),
})
sequenceNumber++
// Emit content_part.done for reasoning
reasoningPart := makeOutputTextPart(finalReasoning)
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.content_part.done",
SequenceNumber: sequenceNumber,
ItemID: currentReasoningID,
OutputIndex: &reasoningOutputIndex,
ContentIndex: &currentReasoningContentIndex,
Part: &reasoningPart,
})
sequenceNumber++
// Emit output_item.done for reasoning
reasoningItem := &schema.ORItemField{
Type: "reasoning",
ID: currentReasoningID,
Status: "completed",
Content: []schema.ORContentPart{reasoningPart},
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done",
SequenceNumber: sequenceNumber,
OutputIndex: &reasoningOutputIndex,
Item: reasoningItem,
})
sequenceNumber++
// Collect reasoning item for storage
collectedOutputItems = append(collectedOutputItems, *reasoningItem)
// Calculate reasoning tokens
reasoningTokens = len(finalReasoning) / 4
if reasoningTokens == 0 && len(finalReasoning) > 0 {
reasoningTokens = 1
}
}
result = finalCleanedResult
// Automatic tool parsing fallback for streaming: parse tool calls from accumulated text
var streamFallbackToolCalls []functions.FuncCallResults
if cfg.FunctionsConfig.AutomaticToolParsingFallback && result != "" {
streamFallbackToolCalls = functions.ParseFunctionCall(result, cfg.FunctionsConfig)
if len(streamFallbackToolCalls) > 0 {
result = functions.StripToolCallMarkup(result)
}
}
// Convert logprobs for streaming events
mcpStreamLogprobs := convertLogprobsForStreaming(noToolLogprobs)
// Emit output_text.done
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_text.done",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &messageOutputIndex,
ContentIndex: &currentContentIndex,
Text: strPtr(result),
Logprobs: logprobsPtr(mcpStreamLogprobs),
})
sequenceNumber++
// Emit content_part.done (with actual logprobs)
resultPart := makeOutputTextPartWithLogprobs(result, noToolLogprobs)
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.content_part.done",
SequenceNumber: sequenceNumber,
ItemID: currentMessageID,
OutputIndex: &messageOutputIndex,
ContentIndex: &currentContentIndex,
Part: &resultPart,
})
sequenceNumber++
// Emit output_item.done (with actual logprobs)
messageItem.Status = "completed"
messageItem.Content = []schema.ORContentPart{makeOutputTextPartWithLogprobs(result, noToolLogprobs)}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done",
SequenceNumber: sequenceNumber,
OutputIndex: &messageOutputIndex,
Item: messageItem,
})
sequenceNumber++
// Emit function_call items from automatic tool parsing fallback
for _, fc := range streamFallbackToolCalls {
toolCallID := fc.ID
if toolCallID == "" {
toolCallID = fmt.Sprintf("fc_%s", uuid.New().String())
}
outputIndex++
functionCallItem := &schema.ORItemField{
Type: "function_call",
ID: toolCallID,
Status: "completed",
CallID: toolCallID,
Name: fc.Name,
Arguments: fc.Arguments,
}
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.added",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: functionCallItem,
})
sequenceNumber++
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.output_item.done",
SequenceNumber: sequenceNumber,
OutputIndex: &outputIndex,
Item: functionCallItem,
})
sequenceNumber++
collectedOutputItems = append(collectedOutputItems, *functionCallItem)
}
// Emit response.completed
now := time.Now().Unix()
// The final output array must use the indices announced in the stream.
// The message is opened first, followed by reasoning and fallback calls.
finalOutputItems := append([]schema.ORItemField{*messageItem}, collectedOutputItems...)
responseCompleted := buildORResponse(responseID, createdAt, &now, "completed", input, finalOutputItems, &schema.ORUsage{
InputTokens: noToolTokenUsage.Prompt,
OutputTokens: noToolTokenUsage.Completion,
TotalTokens: noToolTokenUsage.Prompt + noToolTokenUsage.Completion,
OutputTokensDetails: &schema.OROutputTokensDetails{
ReasoningTokens: reasoningTokens,
},
}, shouldStore)
sendSSEEvent(c, &schema.ORStreamEvent{
Type: "response.completed",
SequenceNumber: sequenceNumber,
Response: responseCompleted,
})
// Store response for future reference (if enabled)
if shouldStore {
store := GetGlobalStore()
store.StoreOwned(responseID, input, responseCompleted, ownerFromContext(c))
}
// Send [DONE]
fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
c.Response().Flush()
return nil
}
// sendSSEEvent sends a Server-Sent Event
func sendSSEEvent(c echo.Context, event *schema.ORStreamEvent) {
normalizeORStreamEvent(event)
data, err := json.Marshal(event)
if err != nil {
xlog.Error("Failed to marshal SSE event", "error", err)
return
}
fmt.Fprintf(c.Response().Writer, "event: %s\ndata: %s\n\n", event.Type, string(data))
}
// normalizeORStreamEvent ensures required fields like Summary are never null.
func normalizeORStreamEvent(event *schema.ORStreamEvent) {
if event.Item != nil && event.Item.Summary == nil {
event.Item.Summary = []schema.ORContentPart{}
}
}
// getTopLogprobs returns the top_logprobs value, defaulting to 0 if nil
func getTopLogprobs(topLogprobs *int) int {
if topLogprobs != nil {
return *topLogprobs
}
return 0
}
// Helper functions for pointer types in streaming events
func strPtr(s string) *string {
return &s
}
func logprobsPtr(lp []schema.ORLogProb) *[]schema.ORLogProb {
return &lp
}
func emptyLogprobs() *[]schema.ORLogProb {
empty := []schema.ORLogProb{}
return &empty
}
// makeOutputTextPart creates an output_text content part with all required fields per Open Responses spec
func makeOutputTextPart(text string) schema.ORContentPart {
return schema.ORContentPartWithLogprobs(text, nil)
}
// makeOutputTextPartWithLogprobs creates an output_text content part with actual logprobs data
func makeOutputTextPartWithLogprobs(text string, logprobs *schema.Logprobs) schema.ORContentPart {
return schema.ORContentPartWithLogprobs(text, logprobs)
}
// convertLogprobsForStreaming converts OpenAI-style logprobs to Open Responses format for streaming events
func convertLogprobsForStreaming(logprobs *schema.Logprobs) []schema.ORLogProb {
if logprobs == nil || len(logprobs.Content) == 0 {
return []schema.ORLogProb{}
}
result := make([]schema.ORLogProb, 0, len(logprobs.Content))
for _, lp := range logprobs.Content {
topLPs := make([]schema.ORTopLogProb, 0, len(lp.TopLogprobs))
for _, tlp := range lp.TopLogprobs {
topLPs = append(topLPs, schema.ORTopLogProb{
Token: tlp.Token,
Logprob: tlp.Logprob,
Bytes: tlp.Bytes,
})
}
result = append(result, schema.ORLogProb{
Token: lp.Token,
Logprob: lp.Logprob,
Bytes: lp.Bytes,
TopLogprobs: topLPs,
})
}
return result
}
// ensureUsageDetails ensures usage has all required detail fields
func ensureUsageDetails(usage *schema.ORUsage) *schema.ORUsage {
if usage == nil {
return nil
}
// Ensure details are always present (not nil)
if usage.InputTokensDetails == nil {
usage.InputTokensDetails = &schema.ORInputTokensDetails{CachedTokens: 0}
}
if usage.OutputTokensDetails == nil {
usage.OutputTokensDetails = &schema.OROutputTokensDetails{ReasoningTokens: 0}
}
return usage
}
// buildORResponse creates a complete ORResponseResource with all required fields
func buildORResponse(responseID string, createdAt int64, completedAt *int64, status string, input *schema.OpenResponsesRequest, outputItems []schema.ORItemField, usage *schema.ORUsage, shouldStore bool) *schema.ORResponseResource {
// Ensure output is never null - always an array
if outputItems == nil {
outputItems = []schema.ORItemField{}
}
// Ensure Summary is never null on any output item
for i := range outputItems {
if outputItems[i].Summary == nil {
outputItems[i].Summary = []schema.ORContentPart{}
}
}
// Ensure tools is never null - always an array
tools := input.Tools
if tools == nil {
tools = []schema.ORFunctionTool{}
}
// Ensure metadata is never null - always a map
metadata := input.Metadata
if metadata == nil {
metadata = map[string]string{}
}
// Set default values for sampling parameters
temperature := 1.0
if input.Temperature != nil {
temperature = *input.Temperature
}
topP := 1.0
if input.TopP != nil {
topP = *input.TopP
}
presencePenalty := 0.0
if input.PresencePenalty != nil {
presencePenalty = *input.PresencePenalty
}
frequencyPenalty := 0.0
if input.FrequencyPenalty != nil {
frequencyPenalty = *input.FrequencyPenalty
}
// Default truncation to "auto"
truncation := "auto"
if input.Truncation != "" {
truncation = input.Truncation
}
// Default service_tier to "default"
serviceTier := "default"
if input.ServiceTier != "" {
serviceTier = input.ServiceTier
}
// Default parallel_tool_calls to true
parallelToolCalls := true
if input.ParallelToolCalls != nil {
parallelToolCalls = *input.ParallelToolCalls
}
// Default tool_choice: "auto" if tools are present, "none" otherwise
var toolChoice any
if input.ToolChoice != nil {
toolChoice = input.ToolChoice
} else if len(tools) > 0 {
toolChoice = "auto"
} else {
toolChoice = "none"
}
// Background defaults to false
background := false
if input.Background != nil {
background = *input.Background
}
// Convert nullable string fields
var previousResponseID *string
if input.PreviousResponseID != "" {
previousResponseID = &input.PreviousResponseID
}
var instructions *string
if input.Instructions != "" {
instructions = &input.Instructions
}
// Convert reasoning
var reasoning *schema.ORReasoning
if input.Reasoning != nil {
reasoning = &schema.ORReasoning{
Effort: input.Reasoning.Effort,
Summary: input.Reasoning.Summary,
}
}
// Build default text config
textConfig := &schema.ORTextConfig{
Format: &schema.ORTextFormat{
Type: "text",
},
}
return &schema.ORResponseResource{
ID: responseID,
Object: "response",
CreatedAt: createdAt,
CompletedAt: completedAt,
Status: status,
Model: input.Model,
Output: outputItems,
Error: nil, // null when no error
IncompleteDetails: nil, // null when complete
PreviousResponseID: previousResponseID,
Instructions: instructions,
// Tool-related fields
Tools: tools,
ToolChoice: toolChoice,
ParallelToolCalls: parallelToolCalls,
MaxToolCalls: input.MaxToolCalls,
// Sampling parameters
Temperature: temperature,
TopP: topP,
PresencePenalty: presencePenalty,
FrequencyPenalty: frequencyPenalty,
TopLogprobs: getTopLogprobs(input.TopLogprobs),
MaxOutputTokens: input.MaxOutputTokens,
// Text format
Text: textConfig,
// Truncation and reasoning
Truncation: truncation,
Reasoning: reasoning,
// Usage
Usage: ensureUsageDetails(usage),
// Metadata and operational flags
Metadata: metadata,
Store: shouldStore,
Background: background,
ServiceTier: serviceTier,
// Safety and caching (nullable, not yet implemented)
SafetyIdentifier: nil,
PromptCacheKey: nil,
}
}
// sendOpenResponsesError sends an error response
func sendOpenResponsesError(c echo.Context, statusCode int, errorType, message, param string) error {
errorResp := map[string]any{
"error": map[string]any{
"type": errorType,
"message": message,
},
}
if param != "" {
errorResp["error"].(map[string]any)["param"] = param
}
return c.JSON(statusCode, errorResp)
}
// convertORToolsToOpenAIFormat converts only tools that have an equivalent in
// the OpenAI-compatible function-tool representation. Native Responses tools
// such as web_search and namespace must not be rewritten as functions.
func convertORToolsToOpenAIFormat(orTools []schema.ORFunctionTool) []functions.Tool {
result := make([]functions.Tool, 0, len(orTools))
for _, t := range orTools {
if t.Type != "function" {
continue
}
result = append(result, functions.Tool{
Type: "function",
Function: functions.Function{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
return result
}
// ownerFromContext returns the identity (user ID) of the authenticated
// caller, or empty string when no authentication was performed (single-key /
// no-auth deployments). It is the value stamped on a response at creation and
// compared on read/cancel/resume to prevent one caller from accessing
// another's response by guessing its ID.
func ownerFromContext(c echo.Context) string {
if u := auth.GetUser(c); u != nil {
return u.ID
}
return ""
}
// GetResponseEndpoint returns a handler for GET /responses/:id
// This endpoint is used for polling background responses or resuming streaming
// @Summary Get a response by ID
// @Tags inference
// @Description Retrieve a response by ID. Can be used for polling background responses or resuming streaming responses.
// @Param id path string true "Response ID"
// @Param stream query string false "Set to 'true' to resume streaming"
// @Param starting_after query int false "Sequence number to resume from (for streaming)"
// @Success 200 {object} schema.ORResponseResource "Response"
// @Failure 400 {object} map[string]any "Bad Request"
// @Failure 404 {object} map[string]any "Not Found"
// @Router /v1/responses/{id} [get]
func GetResponseEndpoint() func(c echo.Context) error {
return func(c echo.Context) error {
responseID := c.Param("id")
if responseID == "" {
return sendOpenResponsesError(c, 400, "invalid_request_error", "response ID is required", "id")
}
store := GetGlobalStore()
stored, err := store.Get(responseID)
if err != nil {
return sendOpenResponsesError(c, 404, "not_found", fmt.Sprintf("response not found: %s", responseID), "id")
}
// Enforce response ownership. Return 404 (not 403) on mismatch so the
// existence of another caller's response is not leaked.
if !accessAllowed(stored, ownerFromContext(c)) {
return sendOpenResponsesError(c, 404, "not_found", fmt.Sprintf("response not found: %s", responseID), "id")
}
// Check if streaming resume is requested
streamParam := c.QueryParam("stream")
if streamParam == "true" {
// Validate that the response was created with streaming enabled
if !stored.StreamEnabled {
return sendOpenResponsesError(c, 400, "invalid_request_error", "cannot stream a response that was not created with stream=true", "stream")
}
// Get starting_after parameter
startingAfter := 0
startingAfterParam := c.QueryParam("starting_after")
if startingAfterParam != "" {
if _, err := fmt.Sscanf(startingAfterParam, "%d", &startingAfter); err != nil {
return sendOpenResponsesError(c, 400, "invalid_request_error", "starting_after must be an integer", "starting_after")
}
}
return handleStreamResume(c, store, responseID, stored, startingAfter)
}
// Non-streaming: return the current response state
stored.mu.RLock()
response := stored.Response
stored.mu.RUnlock()
return c.JSON(200, response)
}
}
// handleStreamResume handles resuming a streaming response from a specific sequence number
func handleStreamResume(c echo.Context, store *ResponseStore, responseID string, stored *StoredResponse, startingAfter int) error {
// The resume buffer is process-local by design (see ErrResponseNotLocal), so
// a resume that the load balancer routed to a replica which is not
// generating this response cannot be served here. Say that plainly: a
// truthful error lets the caller retry against the right replica, whereas
// replaying an empty buffer would look like a stream that simply ended.
if stored.Remote {
return sendOpenResponsesError(c, 409, "invalid_request_error",
fmt.Sprintf("response %s is being generated on another replica (%s) and its stream cannot be resumed here; poll GET /v1/responses/%s instead, or route the resume with session affinity", responseID, stored.OwnerReplica, responseID),
"stream")
}
// Fetch buffered events before committing to an SSE response so an
// offset-lost gap can be reported as a clean HTTP status rather than a
// silently truncated event stream.
events, err := store.GetEventsAfter(responseID, startingAfter)
if err != nil {
if errors.Is(err, ErrResponseNotLocal) {
return sendOpenResponsesError(c, 409, "invalid_request_error",
fmt.Sprintf("response %s is being generated on another replica and its stream cannot be resumed here", responseID), "stream")
}
if errors.Is(err, ErrOffsetLost) {
return sendOpenResponsesError(c, 409, "invalid_request_error", fmt.Sprintf("starting_after=%d is older than the oldest retained event; the resume buffer evicted those events and the stream cannot be resumed from that point", startingAfter), "starting_after")
}
return sendOpenResponsesError(c, 500, "server_error", fmt.Sprintf("failed to get events: %v", err), "")
}
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
// Send all buffered events
for _, event := range events {
fmt.Fprintf(c.Response().Writer, "event: %s\ndata: %s\n\n", event.EventType, string(event.Data))
c.Response().Flush()
}
// Get the current status
stored.mu.RLock()
status := stored.Response.Status
stored.mu.RUnlock()
// If response is still in progress, subscribe to new events
if status == schema.ORStatusQueued || status == schema.ORStatusInProgress {
eventsChan, err := store.GetEventsChan(responseID)
if err != nil {
// Response might have completed, just finish
fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
c.Response().Flush()
return nil
}
// Track last sent sequence number
lastSeq := startingAfter
if len(events) > 0 {
lastSeq = events[len(events)-1].SequenceNumber
}
// Wait for new events or completion
for {
select {
case <-c.Request().Context().Done():
// Client disconnected
return nil
case <-eventsChan:
// New events available
newEvents, err := store.GetEventsAfter(responseID, lastSeq)
if err != nil {
break
}
for _, event := range newEvents {
fmt.Fprintf(c.Response().Writer, "event: %s\ndata: %s\n\n", event.EventType, string(event.Data))
c.Response().Flush()
lastSeq = event.SequenceNumber
}
// Check if response is now complete
stored.mu.RLock()
status = stored.Response.Status
stored.mu.RUnlock()
if status != schema.ORStatusQueued && status != schema.ORStatusInProgress {
fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
c.Response().Flush()
return nil
}
case <-time.After(30 * time.Second):
// Timeout - send keepalive or check status
stored.mu.RLock()
status = stored.Response.Status
stored.mu.RUnlock()
if status != schema.ORStatusQueued && status != schema.ORStatusInProgress {
fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
c.Response().Flush()
return nil
}
}
}
}
// Response already complete
fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
c.Response().Flush()
return nil
}
// CancelResponseEndpoint returns a handler for POST /responses/:id/cancel
// This endpoint cancels a background response if it's still in progress
// @Summary Cancel a response
// @Tags inference
// @Description Cancel a background response if it's still in progress
// @Param id path string true "Response ID"
// @Success 200 {object} schema.ORResponseResource "Response"
// @Failure 400 {object} map[string]any "Bad Request"
// @Failure 404 {object} map[string]any "Not Found"
// @Router /v1/responses/{id}/cancel [post]
func CancelResponseEndpoint() func(c echo.Context) error {
return func(c echo.Context) error {
responseID := c.Param("id")
if responseID == "" {
return sendOpenResponsesError(c, 400, "invalid_request_error", "response ID is required", "id")
}
store := GetGlobalStore()
// Look up first so ownership can be checked before any mutation.
stored, err := store.Get(responseID)
if err != nil {
return sendOpenResponsesError(c, 404, "not_found", fmt.Sprintf("response not found: %s", responseID), "id")
}
// Return 404 (not 403) on owner mismatch so existence is not leaked.
if !accessAllowed(stored, ownerFromContext(c)) {
return sendOpenResponsesError(c, 404, "not_found", fmt.Sprintf("response not found: %s", responseID), "id")
}
response, err := store.Cancel(responseID)
if err != nil {
return sendOpenResponsesError(c, 404, "not_found", fmt.Sprintf("response not found: %s", responseID), "id")
}
// Return the final response object
return c.JSON(200, response)
}
}