mirror of
https://github.com/ollama/ollama.git
synced 2026-09-09 20:53:00 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcdbec90f4 |
No files matched your search
@@ -566,7 +566,7 @@ export const CodexDesktopModelsSettings = forwardRef<
|
||||
<Popover className="relative w-full">
|
||||
<div
|
||||
data-testid="chatgpt-model-picker"
|
||||
className="relative flex min-h-10 w-full flex-wrap items-center gap-2 rounded-lg bg-neutral-50 p-2 ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 dark:bg-neutral-700 dark:ring-neutral-600 dark:hover:bg-neutral-600"
|
||||
className="relative flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-lg bg-neutral-50 px-2 py-1.5 ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 dark:bg-neutral-700 dark:ring-neutral-600 dark:hover:bg-neutral-600"
|
||||
>
|
||||
<PopoverButton
|
||||
aria-label="Add ChatGPT model"
|
||||
@@ -575,7 +575,7 @@ export const CodexDesktopModelsSettings = forwardRef<
|
||||
>
|
||||
<span className="sr-only">Choose ChatGPT models</span>
|
||||
</PopoverButton>
|
||||
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
{selected.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
|
||||
@@ -305,13 +305,15 @@ func (h *CodexDesktop) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
h.logActivity(started, r.Method, suffix, model, route, status, result)
|
||||
if aborted {
|
||||
// net/http must terminate the response rather than finish a truncated
|
||||
// stream successfully. Record the failure before propagating it.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// serveReverseProxy handles the sentinel panic ReverseProxy uses when a
|
||||
// streamed response fails after its headers have already been sent. A normal
|
||||
// net/http server suppresses this panic, but doing so outside this handler
|
||||
// would skip the terminal activity log and let middleware report a false 500.
|
||||
// All other panics remain programming errors and propagate unchanged.
|
||||
// serveReverseProxy catches stream aborts so ServeHTTP can record the outcome
|
||||
// before rethrowing the sentinel. All other panics propagate unchanged.
|
||||
func (h *CodexDesktop) serveReverseProxy(w http.ResponseWriter, r *http.Request) (aborted bool) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
|
||||
@@ -63,53 +63,16 @@ func normalizeFullAccessExecTool(body []byte) ([]byte, error) {
|
||||
if !ok {
|
||||
return body, nil
|
||||
}
|
||||
encodedTools, changed, err := normalizeFullAccessTools(rawTools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !changed {
|
||||
return body, nil
|
||||
}
|
||||
payload["tools"] = encodedTools
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode request: %w", err)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// normalizeFullAccessTools preserves namespace declarations while rewriting
|
||||
// their member functions using the same contract as top-level functions.
|
||||
func normalizeFullAccessTools(rawTools json.RawMessage) (json.RawMessage, bool, error) {
|
||||
if len(rawTools) == 0 {
|
||||
return rawTools, false, nil
|
||||
}
|
||||
var tools []json.RawMessage
|
||||
if err := json.Unmarshal(rawTools, &tools); err != nil {
|
||||
return nil, false, fmt.Errorf("decode tools: %w", err)
|
||||
return nil, fmt.Errorf("decode tools: %w", err)
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i, rawTool := range tools {
|
||||
var tool map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawTool, &tool); err != nil {
|
||||
return nil, false, fmt.Errorf("decode tool: %w", err)
|
||||
}
|
||||
var toolType string
|
||||
if err := json.Unmarshal(tool["type"], &toolType); err == nil && toolType == "namespace" {
|
||||
members, membersChanged, err := normalizeFullAccessTools(tool["tools"])
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("normalize namespace tools: %w", err)
|
||||
}
|
||||
if membersChanged {
|
||||
tool["tools"] = members
|
||||
tools[i], err = json.Marshal(tool)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("encode namespace tool: %w", err)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
return nil, fmt.Errorf("decode tool: %w", err)
|
||||
}
|
||||
var name string
|
||||
if err := json.Unmarshal(tool["name"], &name); err != nil || name != "exec_command" {
|
||||
@@ -118,11 +81,11 @@ func normalizeFullAccessTools(rawTools json.RawMessage) (json.RawMessage, bool,
|
||||
|
||||
var parameters map[string]json.RawMessage
|
||||
if err := json.Unmarshal(tool["parameters"], ¶meters); err != nil {
|
||||
return nil, false, fmt.Errorf("decode exec_command parameters: %w", err)
|
||||
return nil, fmt.Errorf("decode exec_command parameters: %w", err)
|
||||
}
|
||||
var properties map[string]json.RawMessage
|
||||
if err := json.Unmarshal(parameters["properties"], &properties); err != nil {
|
||||
return nil, false, fmt.Errorf("decode exec_command properties: %w", err)
|
||||
return nil, fmt.Errorf("decode exec_command properties: %w", err)
|
||||
}
|
||||
toolChanged := false
|
||||
for _, property := range []string{"sandbox_permissions", "justification", "prefix_rule"} {
|
||||
@@ -138,40 +101,45 @@ func normalizeFullAccessTools(rawTools json.RawMessage) (json.RawMessage, bool,
|
||||
|
||||
encodedProperties, err := json.Marshal(properties)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("encode exec_command properties: %w", err)
|
||||
return nil, fmt.Errorf("encode exec_command properties: %w", err)
|
||||
}
|
||||
parameters["properties"] = encodedProperties
|
||||
if rawRequired, ok := parameters["required"]; ok {
|
||||
var required []string
|
||||
if err := json.Unmarshal(rawRequired, &required); err != nil {
|
||||
return nil, false, fmt.Errorf("decode exec_command required properties: %w", err)
|
||||
return nil, fmt.Errorf("decode exec_command required properties: %w", err)
|
||||
}
|
||||
required = slices.DeleteFunc(required, func(property string) bool {
|
||||
return property == "sandbox_permissions" || property == "justification" || property == "prefix_rule"
|
||||
})
|
||||
parameters["required"], err = json.Marshal(required)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("encode exec_command required properties: %w", err)
|
||||
return nil, fmt.Errorf("encode exec_command required properties: %w", err)
|
||||
}
|
||||
}
|
||||
tool["parameters"], err = json.Marshal(parameters)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("encode exec_command parameters: %w", err)
|
||||
return nil, fmt.Errorf("encode exec_command parameters: %w", err)
|
||||
}
|
||||
tools[i], err = json.Marshal(tool)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("encode exec_command tool: %w", err)
|
||||
return nil, fmt.Errorf("encode exec_command tool: %w", err)
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return rawTools, false, nil
|
||||
return body, nil
|
||||
}
|
||||
|
||||
encodedTools, err := json.Marshal(tools)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("encode tools: %w", err)
|
||||
return nil, fmt.Errorf("encode tools: %w", err)
|
||||
}
|
||||
return encodedTools, true, nil
|
||||
payload["tools"] = encodedTools
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode request: %w", err)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func codexSandboxMode(body []byte) string {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -380,76 +379,6 @@ func TestNormalizeFullAccessExecToolLeavesSandboxedTurnUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFullAccessNamespacedExecTool(t *testing.T) {
|
||||
const tools = `[{"type":"namespace","name":"functions","description":"Command tools","tools":[
|
||||
{"type":"function","name":"exec_command","strict":false,"parameters":{"type":"object","properties":{"cmd":{"type":"string"},"sandbox_permissions":{"type":"string","enum":["use_default","require_escalated"]},"justification":{"type":"string"},"prefix_rule":{"type":"array","items":{"type":"string"}}},"required":["cmd","sandbox_permissions","justification","prefix_rule"],"additionalProperties":false}},
|
||||
{"type":"function","name":"other_tool","parameters":{"type":"object","properties":{"sandbox_permissions":{"type":"string"}}}}
|
||||
]}]`
|
||||
for _, mode := range []string{"danger-full-access", "workspace-write", "read-only", ""} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
metadata, err := json.Marshal(map[string]string{"sandbox_mode": mode})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := []byte(fmt.Sprintf(`{"model":"glm-5.3-flash:cloud","client_metadata":{"x-codex-turn-metadata":%q},"tools":%s}`, metadata, tools))
|
||||
normalized, err := normalizeFullAccessExecTool(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "danger-full-access" {
|
||||
if !bytes.Equal(normalized, body) {
|
||||
t.Fatalf("sandboxed request changed: %s", normalized)
|
||||
}
|
||||
return
|
||||
}
|
||||
var want, got map[string]any
|
||||
if err := json.Unmarshal(body, &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(normalized, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
namespace := want["tools"].([]any)[0].(map[string]any)
|
||||
exec := namespace["tools"].([]any)[0].(map[string]any)
|
||||
parameters := exec["parameters"].(map[string]any)
|
||||
properties := parameters["properties"].(map[string]any)
|
||||
for _, key := range []string{"sandbox_permissions", "justification", "prefix_rule"} {
|
||||
delete(properties, key)
|
||||
}
|
||||
parameters["required"] = []any{"cmd"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("namespace tool normalization did not preserve the expected request: %s", normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFullAccessNamespaceBoundaries(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
tools string
|
||||
wantErr bool
|
||||
}{
|
||||
{"missing members", `[{"type":"namespace","name":"functions"}]`, false},
|
||||
{"null members", `[{"type":"namespace","name":"functions","tools":null}]`, false},
|
||||
{"empty members", `[{"type":"namespace","name":"functions","tools":[]}]`, false},
|
||||
{"no escalation arguments", `[{"type":"namespace","name":"functions","tools":[{"type":"function","name":"exec_command","parameters":{"properties":{"cmd":{"type":"string"}},"required":["cmd"]}}]}]`, false},
|
||||
{"malformed members", `[{"type":"namespace","name":"functions","tools":{}}]`, true},
|
||||
{"malformed parameters", `[{"type":"namespace","name":"functions","tools":[{"type":"function","name":"exec_command","parameters":42}]}]`, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"client_metadata":{"x-codex-turn-metadata":{"sandbox_mode":"danger-full-access"}},"tools":` + tt.tools + `}`)
|
||||
normalized, err := normalizeFullAccessExecTool(body)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("normalization error = %v, want error = %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr && !bytes.Equal(normalized, body) {
|
||||
t.Fatalf("unchanged namespace was rewritten: %s", normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCatalogModelsReadsOptionalThinkingMetadata(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), CodexDesktopModelCatalogFilename)
|
||||
data := []byte(`{"models":[{"slug":"legacy"},{"slug":"binary","thinking":{"supported":true,"levels":["none","medium"],"values":{"none":false,"medium":true}}}]}`)
|
||||
@@ -1711,7 +1640,7 @@ func TestCodexDesktopWritesSafeActivityLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexDesktopRecordsMidstreamAbortWithoutPanicking(t *testing.T) {
|
||||
func TestCodexDesktopRecordsMidstreamAbortAndTerminatesResponse(t *testing.T) {
|
||||
activityLogPath := filepath.Join(t.TempDir(), "codex-proxy.log")
|
||||
streamErr := errors.New("upstream stream failed")
|
||||
handler, err := NewCodexDesktop(CodexDesktopConfig{
|
||||
@@ -1736,22 +1665,32 @@ func TestCodexDesktopRecordsMidstreamAbortWithoutPanicking(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(
|
||||
done := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(done)
|
||||
handler.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://localhost"+CodexDesktopPathPrefix+"/v1/responses",
|
||||
server.URL+CodexDesktopPathPrefix+"/v1/responses",
|
||||
strings.NewReader(`{"model":"glm-5.3-flash:cloud"}`),
|
||||
)
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
req = req.WithContext(context.WithValue(req.Context(), http.ServerContextKey, &http.Server{}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", recorder.Code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := recorder.Body.String(); got != "data: partial\n\n" {
|
||||
t.Fatalf("body = %q, want partial event", got)
|
||||
resp, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
<-done
|
||||
if resp.StatusCode != http.StatusOK || string(body) != "data: partial\n\n" {
|
||||
t.Fatalf("response = %d %q, want partial 200 response", resp.StatusCode, body)
|
||||
}
|
||||
if !errors.Is(readErr, io.ErrUnexpectedEOF) {
|
||||
t.Errorf("read error = %v, want unexpected EOF for aborted stream", readErr)
|
||||
}
|
||||
if got := handler.upstreamErrors.Load(); got != 1 {
|
||||
t.Fatalf("upstream errors = %d, want 1", got)
|
||||
|
||||
+13
-30
@@ -181,15 +181,11 @@ type ResponsesFunctionCall struct {
|
||||
|
||||
func (ResponsesFunctionCall) responsesInputItem() {}
|
||||
|
||||
// ResponsesFunctionCallOutput represents a paired result or standalone named
|
||||
// output from the client.
|
||||
// ResponsesFunctionCallOutput represents a function call result from the client.
|
||||
type ResponsesFunctionCallOutput struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"` // always "function_call_output"
|
||||
CallID string `json:"call_id,omitempty"` // links to the original function call, if any
|
||||
Name string `json:"name,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Output string `json:"output"`
|
||||
Type string `json:"type"` // always "function_call_output"
|
||||
CallID string `json:"call_id"` // links to the original function call
|
||||
Output string `json:"output"` // the function result
|
||||
|
||||
// OutputItems is populated when output is provided as Responses content
|
||||
// items instead of the string shorthand.
|
||||
@@ -198,27 +194,18 @@ type ResponsesFunctionCallOutput struct {
|
||||
|
||||
func (o *ResponsesFunctionCallOutput) UnmarshalJSON(data []byte) error {
|
||||
var aux struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CallID *string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Output json.RawMessage `json:"output"`
|
||||
Type string `json:"type"`
|
||||
CallID string `json:"call_id"`
|
||||
Output json.RawMessage `json:"output"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if aux.CallID != nil && strings.TrimSpace(*aux.CallID) == "" {
|
||||
return errors.New("function output call_id must not be empty")
|
||||
}
|
||||
if aux.CallID == nil && strings.TrimSpace(aux.Name) == "" {
|
||||
return errors.New("standalone function output is missing name")
|
||||
}
|
||||
*o = ResponsesFunctionCallOutput{ID: aux.ID, Type: aux.Type, Name: aux.Name, Namespace: aux.Namespace}
|
||||
if aux.CallID != nil {
|
||||
o.CallID = *aux.CallID
|
||||
}
|
||||
o.Type = aux.Type
|
||||
o.CallID = aux.CallID
|
||||
o.Output = ""
|
||||
o.OutputItems = nil
|
||||
|
||||
if len(aux.Output) == 0 {
|
||||
return nil
|
||||
@@ -667,16 +654,12 @@ func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
message := api.Message{
|
||||
messages = append(messages, api.Message{
|
||||
Role: "tool",
|
||||
Content: content,
|
||||
Images: images,
|
||||
ToolCallID: v.CallID,
|
||||
}
|
||||
if v.CallID == "" {
|
||||
message.ToolName = qualifyNamespaceToolName(v.Namespace, v.Name)
|
||||
}
|
||||
messages = append(messages, message)
|
||||
})
|
||||
case ResponsesToolSearchCall:
|
||||
messages = appendResponseToolCall(messages, api.ToolCall{
|
||||
ID: v.CallID,
|
||||
|
||||
+19
-70
@@ -49,23 +49,14 @@ type OllamaCompactionPayload struct {
|
||||
Version int `json:"version"`
|
||||
Summary string `json:"summary"`
|
||||
Retained []api.Message `json:"retained"`
|
||||
// StandaloneNames preserves Responses identities by retained-message index.
|
||||
// Qualified native names alone cannot distinguish every namespace/member pair.
|
||||
StandaloneNames map[int]compactionFunctionName `json:"standalone_names,omitempty"`
|
||||
}
|
||||
|
||||
type compactionFunctionName struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
}
|
||||
|
||||
// CompactionTranscriptItem is one ordered input item shown to the compaction
|
||||
// model. Ref is request-local and is the only value the model may select.
|
||||
type CompactionTranscriptItem struct {
|
||||
Ref string `json:"ref"`
|
||||
Type string `json:"type"`
|
||||
Message api.Message `json:"message"`
|
||||
StandaloneName *compactionFunctionName `json:"standalone_name,omitempty"`
|
||||
Ref string `json:"ref"`
|
||||
Type string `json:"type"`
|
||||
Message api.Message `json:"message"`
|
||||
}
|
||||
|
||||
type compactionToolMetadata struct {
|
||||
@@ -74,11 +65,10 @@ type compactionToolMetadata struct {
|
||||
}
|
||||
|
||||
type compactionTranscriptItemWire struct {
|
||||
Ref string `json:"ref"`
|
||||
Type string `json:"type"`
|
||||
Message api.Message `json:"message"`
|
||||
StandaloneName *compactionFunctionName `json:"standalone_name,omitempty"`
|
||||
ImageCount int `json:"image_count,omitempty"`
|
||||
Ref string `json:"ref"`
|
||||
Type string `json:"type"`
|
||||
Message api.Message `json:"message"`
|
||||
ImageCount int `json:"image_count,omitempty"`
|
||||
}
|
||||
|
||||
type compactionToolGroup struct {
|
||||
@@ -310,15 +300,6 @@ func decodeOllamaCompactionItem(item json.RawMessage) (OllamaCompactionPayload,
|
||||
}
|
||||
|
||||
func payloadToResponsesItems(payload OllamaCompactionPayload) ([]json.RawMessage, error) {
|
||||
for index, name := range payload.StandaloneNames {
|
||||
if index < 0 || index >= len(payload.Retained) {
|
||||
return nil, fmt.Errorf("standalone name refers to invalid retained-message index %d", index)
|
||||
}
|
||||
message := payload.Retained[index]
|
||||
if message.Role != "tool" || message.ToolCallID != "" || strings.TrimSpace(name.Name) == "" || qualifyNamespaceToolName(name.Namespace, name.Name) != message.ToolName {
|
||||
return nil, fmt.Errorf("standalone name does not match retained message %d", index)
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -341,8 +322,8 @@ func payloadToResponsesItems(payload OllamaCompactionPayload) ([]json.RawMessage
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, call, result)
|
||||
for i, message := range payload.Retained {
|
||||
converted, err := messageToResponsesItems(message, payload.StandaloneNames[i])
|
||||
for _, message := range payload.Retained {
|
||||
converted, err := messageToResponsesItems(message)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid retained message: %w", err)
|
||||
}
|
||||
@@ -351,7 +332,7 @@ func payloadToResponsesItems(payload OllamaCompactionPayload) ([]json.RawMessage
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func messageToResponsesItems(message api.Message, standaloneName compactionFunctionName) ([]json.RawMessage, error) {
|
||||
func messageToResponsesItems(message api.Message) ([]json.RawMessage, error) {
|
||||
var values []any
|
||||
if message.Thinking != "" {
|
||||
values = append(values, map[string]any{
|
||||
@@ -361,19 +342,9 @@ func messageToResponsesItems(message api.Message, standaloneName compactionFunct
|
||||
}
|
||||
if message.Role == "tool" {
|
||||
if message.ToolCallID == "" {
|
||||
if strings.TrimSpace(standaloneName.Name) == "" {
|
||||
return nil, errors.New("retained tool message is missing tool_call_id or standalone name")
|
||||
}
|
||||
output, err := responsesContentValue(message.Content, message.Images)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := map[string]any{"type": "function_call_output", "name": standaloneName.Name, "output": output}
|
||||
if standaloneName.Namespace != "" {
|
||||
value["namespace"] = standaloneName.Namespace
|
||||
}
|
||||
values = append(values, value)
|
||||
} else if message.ToolName == "tool_search" {
|
||||
return nil, errors.New("retained tool message is missing tool_call_id")
|
||||
}
|
||||
if message.ToolName == "tool_search" {
|
||||
if len(message.Images) > 0 {
|
||||
return nil, errors.New("retained tool search output cannot contain images")
|
||||
}
|
||||
@@ -484,13 +455,9 @@ func newResponsesCompactionPlan(req rawResponsesRequest, rawItems []json.RawMess
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("input[%d]: %w", i, err)
|
||||
}
|
||||
entry := CompactionTranscriptItem{
|
||||
items = append(items, CompactionTranscriptItem{
|
||||
Ref: fmt.Sprintf("item_%06d", i+1), Type: kind, Message: message,
|
||||
}
|
||||
if output, ok := item.(ResponsesFunctionCallOutput); ok && output.CallID == "" {
|
||||
entry.StandaloneName = &compactionFunctionName{Name: output.Name, Namespace: output.Namespace}
|
||||
}
|
||||
items = append(items, entry)
|
||||
})
|
||||
}
|
||||
|
||||
groups, forced, err := analyzeCompactionToolState(items)
|
||||
@@ -535,11 +502,7 @@ func compactionMessage(item ResponsesInputItem) (api.Message, string, error) {
|
||||
return api.Message{}, "", err
|
||||
}
|
||||
}
|
||||
message := api.Message{Role: "tool", Content: content, Images: images, ToolCallID: value.CallID}
|
||||
if value.CallID == "" {
|
||||
message.ToolName = qualifyNamespaceToolName(value.Namespace, value.Name)
|
||||
}
|
||||
return message, "function_call_output", nil
|
||||
return api.Message{Role: "tool", Content: content, Images: images, ToolCallID: value.CallID}, "function_call_output", nil
|
||||
case ResponsesToolSearchCall:
|
||||
return api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
ID: value.CallID, Function: api.ToolCallFunction{Name: "tool_search", Arguments: value.Arguments},
|
||||
@@ -608,7 +571,6 @@ func analyzeCompactionToolState(items []CompactionTranscriptItem) ([]compactionT
|
||||
}
|
||||
byCallID := make(map[string]*pendingGroup)
|
||||
ignoredCallIDs := make(map[string]struct{})
|
||||
forced := make(map[string]struct{})
|
||||
var ordered []*pendingGroup
|
||||
|
||||
for i, item := range items {
|
||||
@@ -630,12 +592,6 @@ func analyzeCompactionToolState(items []CompactionTranscriptItem) ([]compactionT
|
||||
ordered = append(ordered, group)
|
||||
case "function_call_output":
|
||||
callID := item.Message.ToolCallID
|
||||
if callID == "" && item.StandaloneName != nil {
|
||||
// Standalone outputs can carry the task instructions. Retain them
|
||||
// without inventing a call or relying on the summary to repeat them.
|
||||
forced[item.Ref] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if _, ignored := ignoredCallIDs[callID]; ignored {
|
||||
continue
|
||||
}
|
||||
@@ -651,6 +607,7 @@ func analyzeCompactionToolState(items []CompactionTranscriptItem) ([]compactionT
|
||||
}
|
||||
}
|
||||
|
||||
forced := make(map[string]struct{})
|
||||
groups := make([]compactionToolGroup, 0, len(ordered))
|
||||
for _, candidate := range ordered {
|
||||
groups = append(groups, candidate.group)
|
||||
@@ -718,7 +675,7 @@ func (p *ResponsesCompactionPlan) TrimForContextLimit() int {
|
||||
message := item.Message
|
||||
message.Images = nil
|
||||
metadata, err := json.Marshal(compactionTranscriptItemWire{
|
||||
Ref: item.Ref, Type: item.Type, Message: message, StandaloneName: item.StandaloneName, ImageCount: len(item.Message.Images),
|
||||
Ref: item.Ref, Type: item.Type, Message: message, ImageCount: len(item.Message.Images),
|
||||
})
|
||||
if err != nil {
|
||||
return 0
|
||||
@@ -835,7 +792,7 @@ func (p *ResponsesCompactionPlan) summaryTranscriptMessages() ([]any, error) {
|
||||
images := message.Images
|
||||
message.Images = nil
|
||||
metadata, err := json.Marshal(compactionTranscriptItemWire{
|
||||
Ref: item.Ref, Type: item.Type, Message: message, StandaloneName: item.StandaloneName, ImageCount: len(images),
|
||||
Ref: item.Ref, Type: item.Type, Message: message, ImageCount: len(images),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -912,21 +869,13 @@ func (p *ResponsesCompactionPlan) Complete(body []byte) (ResponsesCompactionResu
|
||||
}
|
||||
|
||||
retained := make([]api.Message, 0, len(selected))
|
||||
var standaloneNames map[int]compactionFunctionName
|
||||
for _, item := range p.items {
|
||||
if _, ok := selected[item.Ref]; ok {
|
||||
if item.StandaloneName != nil {
|
||||
if standaloneNames == nil {
|
||||
standaloneNames = make(map[int]compactionFunctionName)
|
||||
}
|
||||
standaloneNames[len(retained)] = *item.StandaloneName
|
||||
}
|
||||
retained = append(retained, item.Message)
|
||||
}
|
||||
}
|
||||
payload := OllamaCompactionPayload{
|
||||
Type: OllamaCompactionPayloadType, Version: OllamaCompactionPayloadVersion, Summary: selection.Summary, Retained: retained,
|
||||
StandaloneNames: standaloneNames,
|
||||
}
|
||||
if p.omittedItems > 0 {
|
||||
payload.Summary = fmt.Sprintf(compactionOmissionNotice, p.omittedItems) + "\n\n" + payload.Summary
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestCompactionPreservesAmbiguousStandaloneNames(t *testing.T) {
|
||||
body := []byte(`{"model":"test","input":[
|
||||
{"type":"message","role":"user","content":"Continue."},
|
||||
{"type":"function_call_output","namespace":"a.b","name":"c","output":"first"},
|
||||
{"type":"message","role":"assistant","content":"Acknowledged."},
|
||||
{"type":"function_call_output","namespace":"a","name":"b.c","output":"second"},
|
||||
{"type":"function_call_output","name":"a.b.c","output":"third"},
|
||||
{"type":"message","role":"user","content":"Keep working."}
|
||||
]}`)
|
||||
wantNames := []compactionFunctionName{
|
||||
{Name: "c", Namespace: "a.b"},
|
||||
{Name: "b.c", Namespace: "a"},
|
||||
{Name: "a.b.c"},
|
||||
}
|
||||
wantContent := []string{"first", "second", "third"}
|
||||
standaloneIndices := []int{1, 3, 4}
|
||||
wantOrdinary := map[int]api.Message{
|
||||
0: {Role: "user", Content: "Continue."},
|
||||
2: {Role: "assistant", Content: "Acknowledged."},
|
||||
}
|
||||
wantRetainedCount := len(wantNames) + len(wantOrdinary)
|
||||
for cycle := range 2 {
|
||||
plan, err := PrepareStandaloneCompaction(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var retainRefs []string
|
||||
for _, item := range plan.items {
|
||||
for _, want := range wantOrdinary {
|
||||
if item.Message.Role == want.Role && item.Message.Content == want.Content {
|
||||
retainRefs = append(retainRefs, item.Ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(retainRefs) != len(wantOrdinary) {
|
||||
t.Fatalf("cycle %d: ordinary messages missing from the transcript", cycle)
|
||||
}
|
||||
result, err := plan.Complete(compactionResponseBody(t, map[string]any{
|
||||
"summary": "Continue the task.", "retain_item_ids": retainRefs,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := decodeResultPayload(t, result)
|
||||
if len(payload.Retained) != wantRetainedCount || len(payload.StandaloneNames) != len(wantNames) {
|
||||
t.Fatalf("cycle %d: retained messages or names changed: %+v", cycle, payload)
|
||||
}
|
||||
for i, want := range wantNames {
|
||||
index := standaloneIndices[i]
|
||||
if got := payload.StandaloneNames[index]; got != want {
|
||||
t.Errorf("cycle %d: retained name %d = %+v, want %+v", cycle, index, got, want)
|
||||
}
|
||||
if got := payload.Retained[index]; got.ToolName != "a.b.c" || got.ToolCallID != "" || got.Content != wantContent[i] {
|
||||
t.Errorf("cycle %d: retained message %d changed: %+v", cycle, index, got)
|
||||
}
|
||||
}
|
||||
for index, want := range wantOrdinary {
|
||||
if got := payload.Retained[index]; got.Role != want.Role || got.Content != want.Content {
|
||||
t.Errorf("cycle %d: ordinary retained message %d changed: %+v", cycle, index, got)
|
||||
}
|
||||
}
|
||||
replay, err := json.Marshal(map[string]any{"model": "test", "input": []any{result.Item}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expanded, changed, err := ExpandResponsesCompactionInput(replay)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("cycle %d: changed=%v err=%v", cycle, changed, err)
|
||||
}
|
||||
var request ResponsesRequest
|
||||
if err := json.Unmarshal(expanded, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(request.Input.Items) != 2+wantRetainedCount {
|
||||
t.Fatalf("cycle %d: got %d items, want summary pair and five retained messages", cycle, len(request.Input.Items))
|
||||
}
|
||||
for i, want := range wantNames {
|
||||
index := standaloneIndices[i] + 2
|
||||
output, ok := request.Input.Items[index].(ResponsesFunctionCallOutput)
|
||||
if !ok || output.CallID != "" || output.Name != want.Name || output.Namespace != want.Namespace || output.Output != wantContent[i] {
|
||||
t.Errorf("cycle %d: output %d lost its original identity or order: %+v", cycle, index, request.Input.Items[index])
|
||||
}
|
||||
}
|
||||
chat, err := FromResponsesRequest(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chat.Messages) != 2+wantRetainedCount {
|
||||
t.Fatalf("cycle %d: got %d native messages", cycle, len(chat.Messages))
|
||||
}
|
||||
for i := range wantNames {
|
||||
index := standaloneIndices[i] + 2
|
||||
if got := chat.Messages[index]; got.ToolName != "a.b.c" || got.ToolCallID != "" || got.Content != wantContent[i] {
|
||||
t.Errorf("cycle %d: native output %d changed: %+v", cycle, index, got)
|
||||
}
|
||||
}
|
||||
for index, want := range wantOrdinary {
|
||||
if got := chat.Messages[index+2]; got.Role != want.Role || got.Content != want.Content {
|
||||
t.Errorf("cycle %d: ordinary native message %d changed: %+v", cycle, index+2, got)
|
||||
}
|
||||
}
|
||||
body = expanded
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionRejectsInvalidStandaloneNames(t *testing.T) {
|
||||
standalone := []api.Message{{Role: "tool", ToolName: "workspace.handoff", Content: "Continue the task."}}
|
||||
name := compactionFunctionName{Name: "handoff", Namespace: "workspace"}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
retained []api.Message
|
||||
names map[int]compactionFunctionName
|
||||
}{
|
||||
{name: "negative index", retained: standalone, names: map[int]compactionFunctionName{-1: name}},
|
||||
{name: "index past retained", retained: standalone, names: map[int]compactionFunctionName{1: name}},
|
||||
{name: "missing identity", retained: standalone},
|
||||
{name: "different native name", retained: standalone, names: map[int]compactionFunctionName{0: {Name: "other", Namespace: "workspace"}}},
|
||||
{name: "blank name", retained: standalone, names: map[int]compactionFunctionName{0: {Name: " ", Namespace: "workspace"}}},
|
||||
{name: "user message", retained: []api.Message{{Role: "user", ToolName: "workspace.handoff", Content: "Continue."}}, names: map[int]compactionFunctionName{0: name}},
|
||||
{
|
||||
name: "paired output",
|
||||
retained: []api.Message{
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{ID: "paired", Function: api.ToolCallFunction{Name: "workspace.handoff", Arguments: api.NewToolCallFunctionArguments()}}}},
|
||||
{Role: "tool", ToolCallID: "paired", ToolName: "workspace.handoff", Content: "Finished."},
|
||||
},
|
||||
names: map[int]compactionFunctionName{1: name},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
payload, err := json.Marshal(OllamaCompactionPayload{
|
||||
Type: OllamaCompactionPayloadType, Version: OllamaCompactionPayloadVersion,
|
||||
Summary: "Continue the task.", Retained: test.retained, StandaloneNames: test.names,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": "test", "input": []any{ResponsesCompactionItem{Type: "compaction", EncryptedContent: string(payload)}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := ExpandResponsesCompactionInput(body); err == nil {
|
||||
t.Fatal("accepted invalid standalone name metadata")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionReplaysLegacyPairedPayload(t *testing.T) {
|
||||
// Version 1 payloads written before standalone outputs have no name metadata.
|
||||
payload := `{"type":"ollama_compaction","version":1,"summary":"The file was read.","retained":[
|
||||
{"role":"assistant","tool_calls":[{"id":"paired","function":{"name":"workspace.read","arguments":{}}}]},
|
||||
{"role":"tool","tool_call_id":"paired","tool_name":"workspace.read","content":"File contents."}
|
||||
]}`
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": "test", "input": []any{ResponsesCompactionItem{Type: "compaction", EncryptedContent: payload}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expanded, changed, err := ExpandResponsesCompactionInput(body)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("changed=%v err=%v", changed, err)
|
||||
}
|
||||
var request ResponsesRequest
|
||||
if err := json.Unmarshal(expanded, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(request.Input.Items) != 4 {
|
||||
t.Fatalf("got %d items, want summary and retained pairs", len(request.Input.Items))
|
||||
}
|
||||
call, ok := request.Input.Items[2].(ResponsesFunctionCall)
|
||||
if !ok || call.CallID != "paired" || call.Name != "workspace.read" {
|
||||
t.Fatalf("legacy call changed: %+v", request.Input.Items[2])
|
||||
}
|
||||
output, ok := request.Input.Items[3].(ResponsesFunctionCallOutput)
|
||||
if !ok || output.CallID != "paired" || output.Output != "File contents." {
|
||||
t.Fatalf("legacy output changed: %+v", request.Input.Items[3])
|
||||
}
|
||||
if _, err := PrepareStandaloneCompaction(expanded); err != nil {
|
||||
t.Fatalf("cannot compact legacy replay: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -132,121 +132,6 @@ func TestCompactionTrimKeepsToolPairsAcrossInterleavedResults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionPreservesStandaloneOutputs(t *testing.T) {
|
||||
body := []byte(`{"model":"test","input":[
|
||||
{"type":"message","role":"user","content":"Continue the task."},
|
||||
{"type":"function_call_output","name":"handoff","namespace":"workspace.tools","output":[
|
||||
{"type":"input_text","text":"Keep the original task instructions."},
|
||||
{"type":"input_image","image_url":"` + compactionTestPNG + `"}
|
||||
]},
|
||||
{"type":"function_call_output","call_id":null,"name":"tool_search","output":"A standalone function can have this name."},
|
||||
{"type":"message","role":"assistant","content":"` + strings.Repeat("old reasoning ", 1000) + `"},
|
||||
{"type":"message","role":"user","content":"Latest request."}
|
||||
]}`)
|
||||
plan, err := PrepareStandaloneCompaction(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed := plan.TrimForContextLimit(); removed != 1 {
|
||||
t.Fatalf("removed %d items, want only the old assistant message", removed)
|
||||
}
|
||||
request, err := plan.SummaryRequest("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, marker := range []string{"Keep the original task instructions.", "workspace.tools", "handoff", compactionTestPNG} {
|
||||
if !bytes.Contains(request, []byte(marker)) {
|
||||
t.Errorf("summary request lost %q", marker)
|
||||
}
|
||||
}
|
||||
for cycle := range 2 {
|
||||
result, err := plan.Complete(compactionResponseBody(t, map[string]any{
|
||||
"summary": "Continue the work.", "retain_item_ids": []string{},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := decodeResultPayload(t, result)
|
||||
if len(payload.Retained) != 2 {
|
||||
t.Fatalf("cycle %d: retained %d messages, want both standalone outputs", cycle, len(payload.Retained))
|
||||
}
|
||||
handoff, search := payload.Retained[0], payload.Retained[1]
|
||||
if handoff.ToolName != "workspace.tools.handoff" || handoff.Content != "Keep the original task instructions." || len(handoff.Images) != 1 {
|
||||
t.Fatalf("cycle %d: handoff changed: %+v", cycle, handoff)
|
||||
}
|
||||
if search.ToolName != "tool_search" || search.Content != "A standalone function can have this name." {
|
||||
t.Fatalf("cycle %d: standalone tool_search changed: %+v", cycle, search)
|
||||
}
|
||||
replay, err := json.Marshal(map[string]any{"model": "test", "input": []any{result.Item}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expanded, changed, err := ExpandResponsesCompactionInput(replay)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("cycle %d: changed=%v err=%v", cycle, changed, err)
|
||||
}
|
||||
var decoded ResponsesRequest
|
||||
if err := json.Unmarshal(expanded, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded.Input.Items) != 4 {
|
||||
t.Fatalf("cycle %d: want summary pair and two standalone outputs, got %d items", cycle, len(decoded.Input.Items))
|
||||
}
|
||||
for i, want := range []api.Message{handoff, search} {
|
||||
output, ok := decoded.Input.Items[i+2].(ResponsesFunctionCallOutput)
|
||||
name := payload.StandaloneNames[i]
|
||||
if !ok || output.CallID != "" || output.Name != name.Name || output.Namespace != name.Namespace || output.Output != want.Content {
|
||||
t.Fatalf("cycle %d: standalone output %d lost identity or gained a call: %+v", cycle, i, decoded.Input.Items[i+2])
|
||||
}
|
||||
}
|
||||
chat, err := FromResponsesRequest(decoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chat.Messages) != 4 || chat.Messages[2].ToolName != "workspace.tools.handoff" || !bytes.Equal(chat.Messages[2].Images[0], handoff.Images[0]) {
|
||||
t.Fatalf("cycle %d: replay changed standalone content or images: %+v", cycle, chat.Messages)
|
||||
}
|
||||
plan, err = PrepareStandaloneCompaction(expanded)
|
||||
if err != nil {
|
||||
t.Fatalf("cycle %d: cannot compact replay: %v", cycle, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionStandaloneOutputsDoNotRelaxPairing(t *testing.T) {
|
||||
for _, item := range []string{
|
||||
`{"type":"function_call_output","name":"handoff","call_id":"missing","output":"unmatched"}`,
|
||||
`{"type":"tool_search_output","tools":[]}`,
|
||||
`{"type":"tool_search_output","call_id":null,"tools":[]}`,
|
||||
`{"type":"function_call_output","name":"handoff","call_id":"","output":"empty ID"}`,
|
||||
`{"type":"function_call_output","output":"anonymous"}`,
|
||||
} {
|
||||
t.Run(item, func(t *testing.T) {
|
||||
if _, err := PrepareStandaloneCompaction([]byte(`{"model":"test","input":[` + item + `]}`)); err == nil {
|
||||
t.Fatal("accepted invalid or unmatched output")
|
||||
}
|
||||
})
|
||||
}
|
||||
// A name on a result with a call ID does not turn it into a standalone output.
|
||||
plan, err := PrepareStandaloneCompaction([]byte(`{"model":"test","input":[
|
||||
{"type":"function_call","call_id":"paired","name":"read","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"paired","name":"read","output":"ok"},
|
||||
{"type":"message","role":"assistant","content":"Read completed."}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := plan.Complete(compactionResponseBody(t, map[string]any{
|
||||
"summary": "Finished reading.", "retain_item_ids": []string{},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retained := decodeResultPayload(t, result).Retained; len(retained) != 0 {
|
||||
t.Fatalf("completed pair was forced as standalone state: %+v", retained)
|
||||
}
|
||||
}
|
||||
|
||||
func compactionResponseBody(t *testing.T, selection map[string]any) []byte {
|
||||
t.Helper()
|
||||
arguments, err := json.Marshal(selection)
|
||||
|
||||
@@ -331,57 +331,6 @@ func TestUnmarshalResponsesInputItem(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestResponsesStandaloneFunctionOutput(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
fields string
|
||||
wantName string
|
||||
wantError bool
|
||||
}{
|
||||
{"omitted call ID", `"name":"handoff","namespace":"workspace",`, "workspace.handoff", false},
|
||||
{"null call ID", `"call_id":null,"name":"handoff","namespace":"workspace",`, "workspace.handoff", false},
|
||||
{"no namespace", `"name":"handoff",`, "handoff", false},
|
||||
{"null namespace", `"name":"handoff","namespace":null,`, "handoff", false},
|
||||
{"empty call ID", `"call_id":"","name":"handoff",`, "", true},
|
||||
{"blank call ID", `"call_id":" ","name":"handoff",`, "", true},
|
||||
{"missing name", ``, "", true},
|
||||
{"null name", `"name":null,`, "", true},
|
||||
{"empty name", `"name":"",`, "", true},
|
||||
{"blank name", `"name":" ",`, "", true},
|
||||
{"namespace only", `"namespace":"workspace",`, "", true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"test","input":[{"type":"function_call_output","id":"fco_handoff",` + tt.fields + `"output":[{"type":"input_text","text":"Continue the task."}]}]}`)
|
||||
var request ResponsesRequest
|
||||
err := json.Unmarshal(body, &request)
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Fatal("accepted invalid standalone output")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := request.Input.Items[0].(ResponsesFunctionCallOutput)
|
||||
if output.ID != "fco_handoff" || output.Name != "handoff" || output.CallID != "" || len(output.OutputItems) != 1 {
|
||||
t.Fatalf("standalone identity or content changed: %+v", output)
|
||||
}
|
||||
chat, err := FromResponsesRequest(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chat.Messages) != 1 {
|
||||
t.Fatalf("got %d messages, want the standalone output only", len(chat.Messages))
|
||||
}
|
||||
message := chat.Messages[0]
|
||||
if message.Role != "tool" || message.ToolName != tt.wantName || message.ToolCallID != "" || len(message.ToolCalls) != 0 || message.Content != "Continue the task." {
|
||||
t.Fatalf("standalone output lost its name/content or gained a call: %+v", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromResponsesRequestIgnoresReplayedWebSearchCall(t *testing.T) {
|
||||
req := ResponsesRequest{
|
||||
Model: "test",
|
||||
@@ -1358,7 +1307,7 @@ func TestFromResponsesRequest_FunctionCallOutput(t *testing.T) {
|
||||
"input": [
|
||||
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "what is the weather?"}]},
|
||||
{"type": "function_call", "call_id": "call_abc123", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"},
|
||||
{"type": "function_call_output", "call_id": "call_abc123", "name": "stale_name", "namespace": "stale_namespace", "output": "sunny, 72F"}
|
||||
{"type": "function_call_output", "call_id": "call_abc123", "output": "sunny, 72F"}
|
||||
]
|
||||
}`
|
||||
|
||||
@@ -1432,9 +1381,6 @@ func TestFromResponsesRequest_FunctionCallOutput(t *testing.T) {
|
||||
if toolMsg.ToolCallID != "call_abc123" {
|
||||
t.Errorf("expected ToolCallID 'call_abc123', got %q", toolMsg.ToolCallID)
|
||||
}
|
||||
if toolMsg.ToolName != "" {
|
||||
t.Errorf("paired output name %q would override the original call's name", toolMsg.ToolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromResponsesRequest_FunctionCallOutputContentArray(t *testing.T) {
|
||||
|
||||
@@ -264,7 +264,9 @@ func proxyCloudRequestWithPath(c *gin.Context, body []byte, path string, disable
|
||||
"request_context_err", ctxErr,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
// Do not finish an incomplete upstream response as a successful stream.
|
||||
// Propagate the abort through recovery middleware to net/http.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ollama/ollama/internal/proxy"
|
||||
)
|
||||
|
||||
func TestCodexProxyHealthRoute(t *testing.T) {
|
||||
@@ -23,6 +34,204 @@ func TestCodexProxyHealthRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProxyStreamTermination(t *testing.T) {
|
||||
for _, cloudHop := range []bool{false, true} {
|
||||
route := "direct"
|
||||
if cloudHop {
|
||||
route = "cloud-hop"
|
||||
}
|
||||
for _, http2 := range []bool{false, true} {
|
||||
protocol := "HTTP1"
|
||||
if http2 {
|
||||
protocol = "HTTP2"
|
||||
}
|
||||
for _, abort := range []bool{true, false} {
|
||||
outcome := "complete"
|
||||
if abort {
|
||||
outcome = "disconnect"
|
||||
}
|
||||
t.Run(route+"/"+protocol+"/"+outcome, func(t *testing.T) {
|
||||
const partial = "data: {\"type\":\"response.created\"}\n\n"
|
||||
const completed = "data: {\"type\":\"response.completed\"}\n\ndata: [DONE]\n\n"
|
||||
release := make(chan struct{})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, partial)
|
||||
w.(http.Flusher).Flush()
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
if abort {
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
_, _ = io.WriteString(w, completed)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "false")
|
||||
t.Setenv("OLLAMA_HOST", upstream.URL)
|
||||
catalogDir := filepath.Join(home, ".codex")
|
||||
if err := os.MkdirAll(catalogDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(catalogDir, proxy.CodexDesktopRoutingCatalogFilename), []byte(`{"models":[{"slug":"stream-probe:cloud"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
serverLog, err := os.Create(filepath.Join(home, "server.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer serverLog.Close()
|
||||
oldLogger, oldWriter, oldErrorWriter := slog.Default(), gin.DefaultWriter, gin.DefaultErrorWriter
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(serverLog, nil)))
|
||||
gin.DefaultWriter, gin.DefaultErrorWriter = serverLog, serverLog
|
||||
defer func() {
|
||||
slog.SetDefault(oldLogger)
|
||||
gin.DefaultWriter, gin.DefaultErrorWriter = oldWriter, oldErrorWriter
|
||||
}()
|
||||
|
||||
// The extra HTTP listener exercises the same two-hop route used by
|
||||
// ollama serve: /api/codex/v1/responses -> /v1/responses -> cloud.
|
||||
var inner *httptest.Server
|
||||
if cloudHop {
|
||||
inner = httptest.NewUnstartedServer(nil)
|
||||
defer inner.Close()
|
||||
t.Setenv("OLLAMA_HOST", "http://"+inner.Listener.Addr().String())
|
||||
original := cloudProxyBaseURL
|
||||
cloudProxyBaseURL = upstream.URL
|
||||
defer func() { cloudProxyBaseURL = original }()
|
||||
}
|
||||
handler, err := (&Server{}).GenerateRoutes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inner != nil {
|
||||
inner.Config.Handler = handler
|
||||
inner.Start()
|
||||
}
|
||||
done := make(chan struct{})
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/codex/v1/responses" {
|
||||
defer close(done)
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
}))
|
||||
server.EnableHTTP2 = http2
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
client.Timeout = 5 * time.Second
|
||||
resp, err := client.Post(server.URL+"/api/codex/v1/responses", "application/json", strings.NewReader(`{"model":"stream-probe:cloud","stream":true,"input":[{"role":"user","content":"Synthetic stream probe"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
wantProto := 1
|
||||
if http2 {
|
||||
wantProto = 2
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || resp.ProtoMajor != wantProto {
|
||||
t.Fatalf("response = %s %s", resp.Proto, resp.Status)
|
||||
}
|
||||
prefix := make([]byte, len(partial))
|
||||
if _, err := io.ReadFull(resp.Body, prefix); err != nil || string(prefix) != partial {
|
||||
t.Fatalf("partial response = %q, error = %v", prefix, err)
|
||||
}
|
||||
// Disconnect only after the real client has received partial output.
|
||||
select {
|
||||
case release <- struct{}{}:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("upstream did not accept release")
|
||||
}
|
||||
rest, readErr := io.ReadAll(resp.Body)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("proxy handler did not finish")
|
||||
}
|
||||
t.Logf("client: protocol=%s status=%d partial=%q remaining=%q read_error=%v", resp.Proto, resp.StatusCode, prefix, rest, readErr)
|
||||
if abort {
|
||||
if !http2 && !errors.Is(readErr, io.ErrUnexpectedEOF) {
|
||||
t.Errorf("HTTP/1 read error = %v, want unexpected EOF", readErr)
|
||||
} else if http2 && (readErr == nil || !strings.Contains(readErr.Error(), "INTERNAL_ERROR")) {
|
||||
t.Errorf("HTTP/2 read error = %v, want stream reset", readErr)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Errorf("unexpected output after abort: %q", rest)
|
||||
}
|
||||
} else if readErr != nil || string(rest) != completed {
|
||||
t.Errorf("complete stream: remaining=%q error=%v", rest, readErr)
|
||||
}
|
||||
|
||||
wantResult, wantErrors := "ok", 0
|
||||
if abort {
|
||||
wantResult, wantErrors = "stream_error", 1
|
||||
}
|
||||
logData, err := os.ReadFile(filepath.Join(home, ".ollama", "logs", codexDesktopLogFilename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("activity: %s", logData)
|
||||
if !strings.Contains(string(logData), "status=200 ") || !strings.Contains(string(logData), "result="+wantResult) {
|
||||
t.Errorf("activity log did not record %s: %s", wantResult, logData)
|
||||
}
|
||||
status, err := client.Get(server.URL + "/api/codex/_status")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer status.Body.Close()
|
||||
var metrics struct {
|
||||
UpstreamErrors int `json:"upstream_errors"`
|
||||
}
|
||||
if err := json.NewDecoder(status.Body).Decode(&metrics); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metrics.UpstreamErrors != wantErrors {
|
||||
t.Errorf("upstream_errors=%d, want %d", metrics.UpstreamErrors, wantErrors)
|
||||
}
|
||||
t.Logf("status: upstream_errors=%d", metrics.UpstreamErrors)
|
||||
logs, err := os.ReadFile(serverLog.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, line := range strings.Split(string(logs), "\n") {
|
||||
if strings.Contains(line, "level=WARN") || strings.Contains(line, "level=ERROR") || strings.Contains(line, "[Recovery]") {
|
||||
t.Logf("server: %s", line)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(logs), "panic recovered") || strings.Contains(string(logs), "override status code 200 with 500") {
|
||||
t.Errorf("middleware treated a stream abort as an application panic:\n%s", logs)
|
||||
}
|
||||
if abort && !strings.Contains(string(logs), "Codex proxy response stream aborted") {
|
||||
t.Error("server log lost the stream failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRecoversOrdinaryPanic(t *testing.T) {
|
||||
handler, err := (&Server{}).GenerateRoutes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler.(*gin.Engine).GET("/test-panic", func(*gin.Context) { panic("test panic") })
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test-panic", nil)
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
handler.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("panic status = %d, want 500", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProxyWebSocketUpgradeRequestsHTTPFallback(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
handler, err := (&Server{}).GenerateRoutes()
|
||||
|
||||
@@ -7,16 +7,12 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ollama/ollama/internal/proxy"
|
||||
"github.com/ollama/ollama/openai"
|
||||
)
|
||||
|
||||
@@ -399,169 +395,6 @@ func TestResponsesCompactionTriggerReturnsCodexStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesCompactionPreservesStandaloneOutputIntoNextTurn(t *testing.T) {
|
||||
const imageURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
stream bool
|
||||
namespace string
|
||||
nullID bool
|
||||
}{
|
||||
{name: "Codex trigger with namespaced handoff", stream: true, namespace: "workspace"},
|
||||
{name: "compact endpoint with null call ID", nullID: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
local, capture := newCompactionTestServer(t, func(attempt int, w http.ResponseWriter, _ *http.Request, _ []byte) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if attempt%2 == 1 {
|
||||
// The compactor does not select the handoff for retention.
|
||||
_, _ = w.Write(summaryResponse(t, "Continue the task.", nil))
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"id":"resp_next","object":"response","status":"completed","model":"fixture","output":[],"usage":null}`)
|
||||
})
|
||||
endpoint, path := local, "/v1/responses/compact"
|
||||
if tt.stream {
|
||||
catalogPath := filepath.Join(t.TempDir(), proxy.CodexDesktopRoutingCatalogFilename)
|
||||
if err := os.WriteFile(catalogPath, []byte(`{"models":[{"slug":"fixture:cloud"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := proxy.NewCodexDesktop(proxy.CodexDesktopConfig{
|
||||
OllamaURL: local.URL, ChatGPTURL: local.URL, OpenAIURL: local.URL,
|
||||
RoutingCatalogPath: catalogPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint = httptest.NewServer(handler)
|
||||
t.Cleanup(endpoint.Close)
|
||||
path = proxy.CodexDesktopPathPrefix + "/v1/responses"
|
||||
}
|
||||
|
||||
output := []any{
|
||||
map[string]any{"type": "input_text", "text": "Use the supplied architecture diagram."},
|
||||
map[string]any{"type": "input_image", "detail": "auto", "image_url": imageURL},
|
||||
}
|
||||
standalone := map[string]any{"type": "function_call_output", "name": "handoff", "output": output}
|
||||
if tt.namespace != "" {
|
||||
standalone["namespace"] = tt.namespace
|
||||
}
|
||||
if tt.nullID {
|
||||
standalone["call_id"] = nil
|
||||
}
|
||||
input := []any{
|
||||
map[string]any{"type": "message", "role": "user", "content": "Implement the feature."},
|
||||
standalone,
|
||||
map[string]any{"type": "message", "role": "assistant", "content": "I have read the handoff."},
|
||||
map[string]any{"type": "message", "role": "user", "content": "Continue."},
|
||||
}
|
||||
for cycle := range 2 {
|
||||
compactInput := append([]any(nil), input...)
|
||||
if tt.stream {
|
||||
compactInput = append(compactInput, map[string]any{"type": "compaction_trigger"})
|
||||
}
|
||||
request, err := json.Marshal(map[string]any{"model": "fixture:cloud", "stream": tt.stream, "input": compactInput})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, header, body := postCompactionRequest(t, endpoint, path, string(request))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("cycle %d compact status=%d body=%s", cycle, status, body)
|
||||
}
|
||||
var compacted openai.ResponsesCompactionItem
|
||||
if tt.stream {
|
||||
if !strings.HasPrefix(header.Get("Content-Type"), "text/event-stream") {
|
||||
t.Fatalf("unexpected stream content-type %q", header.Get("Content-Type"))
|
||||
}
|
||||
done := 0
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
data, ok := strings.CutPrefix(line, "data: ")
|
||||
if !ok || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
Item openai.ResponsesCompactionItem `json:"item"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Type == "response.output_item.done" {
|
||||
compacted = event.Item
|
||||
done++
|
||||
}
|
||||
}
|
||||
if done != 1 {
|
||||
t.Fatalf("expected one completed compaction item, got %d: %s", done, body)
|
||||
}
|
||||
} else {
|
||||
var response openai.ResponsesCompactedResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Output) != 1 {
|
||||
t.Fatalf("expected one compaction item: %s", body)
|
||||
}
|
||||
compacted = response.Output[0]
|
||||
}
|
||||
if compacted.Type != "compaction" {
|
||||
t.Fatalf("unexpected output item: %+v", compacted)
|
||||
}
|
||||
|
||||
input = []any{compacted, map[string]any{"type": "message", "role": "user", "content": "Continue."}}
|
||||
request, err = json.Marshal(map[string]any{"model": "fixture:cloud", "stream": false, "input": input})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, _, body = postCompactionRequest(t, endpoint, strings.TrimSuffix(path, "/compact"), string(request))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("cycle %d replay status=%d body=%s", cycle, status, body)
|
||||
}
|
||||
paths, bodies := capture.snapshot()
|
||||
if len(paths) != 2*(cycle+1) || paths[len(paths)-1] != "/v1/responses" {
|
||||
t.Fatalf("unexpected upstream requests: %v", paths)
|
||||
}
|
||||
var forwarded struct {
|
||||
Input []map[string]any `json:"input"`
|
||||
}
|
||||
if err := json.Unmarshal(bodies[len(bodies)-1], &forwarded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputs := 0
|
||||
var summaryCallID string
|
||||
for _, item := range forwarded.Input {
|
||||
if item["type"] == "compaction" {
|
||||
t.Fatalf("opaque compaction item reached upstream: %+v", item)
|
||||
}
|
||||
if item["type"] == "function_call" {
|
||||
if item["name"] != "ollama_compaction_summary" {
|
||||
t.Fatalf("unexpected synthetic function call: %+v", item)
|
||||
}
|
||||
summaryCallID, _ = item["call_id"].(string)
|
||||
continue
|
||||
}
|
||||
if item["type"] != "function_call_output" {
|
||||
continue
|
||||
}
|
||||
if summaryCallID != "" && item["call_id"] == summaryCallID {
|
||||
continue
|
||||
}
|
||||
outputs++
|
||||
if item["call_id"] != nil || item["name"] != "handoff" || !reflect.DeepEqual(item["output"], output) {
|
||||
t.Fatalf("standalone output changed during replay: %+v", item)
|
||||
}
|
||||
if namespace, _ := item["namespace"].(string); namespace != tt.namespace {
|
||||
t.Fatalf("namespace=%q, want %q", namespace, tt.namespace)
|
||||
}
|
||||
}
|
||||
if outputs != 1 {
|
||||
t.Fatalf("expected one replayed standalone output, got %d: %s", outputs, bodies[len(bodies)-1])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesCompactionRepairsMalformedSummaryOnce(t *testing.T) {
|
||||
local, capture := newCompactionTestServer(t, func(attempt int, w http.ResponseWriter, _ *http.Request, _ []byte) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
+11
-1
@@ -20,6 +20,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -1876,7 +1877,16 @@ func (s *Server) GenerateRoutes() (http.Handler, error) {
|
||||
}
|
||||
corsConfig.AllowOrigins = envconfig.AllowedOrigins()
|
||||
|
||||
r := gin.Default()
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.CustomRecoveryWithWriter(nil, func(c *gin.Context, recovered any) {
|
||||
// ReverseProxy uses this sentinel to terminate incomplete responses.
|
||||
// Let net/http close the connection or reset the HTTP/2 stream.
|
||||
if recovered == http.ErrAbortHandler {
|
||||
panic(recovered)
|
||||
}
|
||||
slog.Error("request panic recovered", "panic", recovered, "stack", string(debug.Stack()))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}))
|
||||
r.HandleMethodNotAllowed = true
|
||||
r.Use(
|
||||
cors.New(corsConfig),
|
||||
|
||||
Reference in new issue
Block a user