diff --git a/docs/en/api-reference/openai-api.md b/docs/en/api-reference/openai-api.md index ff7edfd4e..0fb957ea8 100644 --- a/docs/en/api-reference/openai-api.md +++ b/docs/en/api-reference/openai-api.md @@ -54,16 +54,19 @@ fmt.Println(responseText) ### OpenAI Responses API -AxonHub provides partial support for the OpenAI Responses API. This API offers a simplified interface for single-turn interactions. +AxonHub provides partial support for the OpenAI Responses API, including continued conversations. **Endpoints:** - `POST /v1/responses` - Generate a response **Capabilities:** - ✅ `previous_response_id` passthrough is supported for continued Responses conversations on the same upstream channel +- ✅ When a Responses request is routed to a Chat Completions channel, AxonHub expands `previous_response_id` into explicit Chat history within the same project and API-key scope - ✅ Basic response generation is fully functional - ✅ Streaming responses are supported +Responses-to-Chat history expansion requires both request and response body storage to be enabled for the referenced turns. If the referenced response is missing, outside the current scope, or its bodies were not retained, AxonHub returns `400 invalid_request_error`. Storage service failures remain server errors. Previous turns' top-level `instructions` are not inherited, matching Responses API semantics. + **Example Request:** ```go import ( diff --git a/docs/zh/api-reference/openai-api.md b/docs/zh/api-reference/openai-api.md index e2dd0251d..94c40db65 100644 --- a/docs/zh/api-reference/openai-api.md +++ b/docs/zh/api-reference/openai-api.md @@ -54,16 +54,19 @@ fmt.Println(responseText) ### OpenAI Responses API -AxonHub 提供对 OpenAI Responses API 的部分支持。该 API 为单轮交互提供了简化的接口。 +AxonHub 提供对 OpenAI Responses API 的部分支持,包括连续对话。 **端点:** - `POST /v1/responses` - 生成响应 **能力:** - ✅ 支持 `previous_response_id` 透传,可用于同一上游 channel 上的连续 Responses 对话复用 +- ✅ 当 Responses 请求被路由到 Chat Completions channel 时,AxonHub 会在同一项目和 API Key 作用域内将 `previous_response_id` 展开为显式 Chat 历史 - ✅ 基本响应生成完全可用 - ✅ 支持流式响应 +Responses 到 Chat 的历史展开要求被引用轮次同时启用请求体和响应体存储。引用响应不存在、超出当前作用域或正文未保留时,AxonHub 返回 `400 invalid_request_error`;存储服务故障仍按服务端错误返回。历史轮次的顶层 `instructions` 不会被继承,与 Responses API 语义一致。 + **示例请求:** ```go import ( diff --git a/internal/ent/internal/schema.go b/internal/ent/internal/schema.go index 33d77ccf1..4dc6a1e26 100644 --- a/internal/ent/internal/schema.go +++ b/internal/ent/internal/schema.go @@ -6,4 +6,4 @@ // Package internal holds a loadable version of the latest schema. package internal -const Schema = "{\"Schema\":\"github.com/looplj/axonhub/internal/ent/schema\",\"Package\":\"github.com/looplj/axonhub/internal/ent\",\"Schemas\":[{\"name\":\"APIKey\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"api_keys\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"api_keys\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":32}}},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"The creator of the API key\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"apikey.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"user\",\"V\":\"user\"},{\"N\":\"service_account\",\"V\":\"service_account\"},{\"N\":\"noauth\",\"V\":\"noauth\"},{\"N\":\"personal\",\"V\":\"personal\"}],\"default\":true,\"default_value\":\"user\",\"default_kind\":24,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"API Key type: user, service_account, noauth, or personal\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"apikey.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"enabled\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[\"read_channels\",\"write_requests\"],\"default_kind\":23,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"API Key specific scopes. For user type: default read_channels, write_requests (immutable). For service_account: custom scopes.\"},{\"name\":\"profiles\",\"type\":{\"Type\":3,\"Ident\":\"*objects.APIKeyProfiles\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"APIKeyProfiles\",\"Ident\":\"objects.APIKeyProfiles\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"activeProfile\":\"\",\"profiles\":null},\"default_kind\":22,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"allowed_ips\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"IP CIDR allowlist for this API key. If non-empty, only requests from matching source IPs are accepted.\"}],\"indexes\":[{\"fields\":[\"user_id\"],\"storage_key\":\"api_keys_by_user_id\"},{\"fields\":[\"project_id\"],\"storage_key\":\"api_keys_by_project_id\"},{\"unique\":true,\"fields\":[\"key\"],\"storage_key\":\"api_keys_by_key\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"APIKeyProfileTemplate\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"api_key_profile_templates\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":32}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template description\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Project ID, set via project edge\"},{\"name\":\"profile\",\"type\":{\"Type\":3,\"Ident\":\"*objects.APIKeyProfile\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"APIKeyProfile\",\"Ident\":\"objects.APIKeyProfile\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Clone\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"*objects.APIKeyProfile\",\"Kind\":22,\"PkgPath\":\"\",\"Methods\":null}]},\"MatchChannelTags\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"default\":true,\"default_value\":{\"modelMappings\":null,\"name\":\"\"},\"default_kind\":22,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"project_id\",\"name\",\"deleted_at\"],\"storage_key\":\"api_key_profile_templates_by_project_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Channel\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"channel_probes\",\"type\":\"ChannelProbe\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"channel_model_prices\",\"type\":\"ChannelModelPrice\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"provider_quota_status\",\"type\":\"ProviderQuotaStatus\",\"unique\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"channel.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"openai\",\"V\":\"openai\"},{\"N\":\"openai_responses\",\"V\":\"openai_responses\"},{\"N\":\"atlascloud\",\"V\":\"atlascloud\"},{\"N\":\"cline\",\"V\":\"cline\"},{\"N\":\"codex\",\"V\":\"codex\"},{\"N\":\"vercel\",\"V\":\"vercel\"},{\"N\":\"anthropic\",\"V\":\"anthropic\"},{\"N\":\"anthropic_aws\",\"V\":\"anthropic_aws\"},{\"N\":\"anthropic_gcp\",\"V\":\"anthropic_gcp\"},{\"N\":\"gemini_openai\",\"V\":\"gemini_openai\"},{\"N\":\"gemini\",\"V\":\"gemini\"},{\"N\":\"gemini_vertex\",\"V\":\"gemini_vertex\"},{\"N\":\"deepseek\",\"V\":\"deepseek\"},{\"N\":\"deepseek_anthropic\",\"V\":\"deepseek_anthropic\"},{\"N\":\"deepinfra\",\"V\":\"deepinfra\"},{\"N\":\"qiniu\",\"V\":\"qiniu\"},{\"N\":\"fireworks\",\"V\":\"fireworks\"},{\"N\":\"doubao\",\"V\":\"doubao\"},{\"N\":\"doubao_anthropic\",\"V\":\"doubao_anthropic\"},{\"N\":\"moonshot\",\"V\":\"moonshot\"},{\"N\":\"moonshot_anthropic\",\"V\":\"moonshot_anthropic\"},{\"N\":\"zhipu\",\"V\":\"zhipu\"},{\"N\":\"zai\",\"V\":\"zai\"},{\"N\":\"zhipu_anthropic\",\"V\":\"zhipu_anthropic\"},{\"N\":\"zai_anthropic\",\"V\":\"zai_anthropic\"},{\"N\":\"anthropic_fake\",\"V\":\"anthropic_fake\"},{\"N\":\"openai_fake\",\"V\":\"openai_fake\"},{\"N\":\"openrouter\",\"V\":\"openrouter\"},{\"N\":\"xiaomi\",\"V\":\"xiaomi\"},{\"N\":\"xiaomi_anthropic\",\"V\":\"xiaomi_anthropic\"},{\"N\":\"xai\",\"V\":\"xai\"},{\"N\":\"ppio\",\"V\":\"ppio\"},{\"N\":\"siliconflow\",\"V\":\"siliconflow\"},{\"N\":\"volcengine\",\"V\":\"volcengine\"},{\"N\":\"volcengine_anthropic\",\"V\":\"volcengine_anthropic\"},{\"N\":\"longcat\",\"V\":\"longcat\"},{\"N\":\"longcat_anthropic\",\"V\":\"longcat_anthropic\"},{\"N\":\"minimax\",\"V\":\"minimax\"},{\"N\":\"minimax_anthropic\",\"V\":\"minimax_anthropic\"},{\"N\":\"aihubmix\",\"V\":\"aihubmix\"},{\"N\":\"aihubmix_anthropic\",\"V\":\"aihubmix_anthropic\"},{\"N\":\"burncloud\",\"V\":\"burncloud\"},{\"N\":\"modelscope\",\"V\":\"modelscope\"},{\"N\":\"bailian\",\"V\":\"bailian\"},{\"N\":\"bailian_anthropic\",\"V\":\"bailian_anthropic\"},{\"N\":\"moonshot_coding\",\"V\":\"moonshot_coding\"},{\"N\":\"jina\",\"V\":\"jina\"},{\"N\":\"github\",\"V\":\"github\"},{\"N\":\"github_copilot\",\"V\":\"github_copilot\"},{\"N\":\"claudecode\",\"V\":\"claudecode\"},{\"N\":\"cerebras\",\"V\":\"cerebras\"},{\"N\":\"antigravity\",\"V\":\"antigravity\"},{\"N\":\"nanogpt\",\"V\":\"nanogpt\"},{\"N\":\"nanogpt_responses\",\"V\":\"nanogpt_responses\"},{\"N\":\"opencode_go\",\"V\":\"opencode_go\"},{\"N\":\"opencode_go_anthropic\",\"V\":\"opencode_go_anthropic\"},{\"N\":\"ollama\",\"V\":\"ollama\"},{\"N\":\"ollama_anthropic\",\"V\":\"ollama_anthropic\"},{\"N\":\"evolink\",\"V\":\"evolink\"},{\"N\":\"evolink_anthropic\",\"V\":\"evolink_anthropic\"}],\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"TYPE\"}}},{\"name\":\"base_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"channel.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"STATUS\",\"Skip\":16}}},{\"name\":\"credentials\",\"type\":{\"Type\":3,\"Ident\":\"objects.ChannelCredentials\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ChannelCredentials\",\"Ident\":\"objects.ChannelCredentials\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"GetAllAPIKeys\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"GetEnabledAPIKeys\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]objects.DisabledAPIKey\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"IsOAuth\":{\"In\":[],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"disabled_api_keys\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.DisabledAPIKey\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.DisabledAPIKey\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Disabled API keys with metadata (sensitive; requires channel write permission)\"},{\"name\":\"supported_models\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"manual_models\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"auto_sync_supported_models\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"auto_sync_model_pattern\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Regex pattern to filter models during auto-sync. Empty string means no filtering.\"},{\"name\":\"tags\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"default_test_model\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"policies\",\"type\":{\"Type\":3,\"Ident\":\"objects.ChannelPolicies\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ChannelPolicies\",\"Ident\":\"objects.ChannelPolicies\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"stream\":\"unlimited\"},\"default_kind\":25,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ChannelSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ChannelSettings\",\"Ident\":\"objects.ChannelSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"autoTrimedModelPrefixes\":null,\"extraModelPrefix\":\"\",\"hideMappedModels\":false,\"hideOriginalModels\":false,\"lowercaseModelId\":false,\"modelMappings\":[],\"overrideHeaders\":null,\"overrideParameters\":\"\",\"transformOptions\":{\"forceArrayInputs\":false,\"forceArrayInstructions\":false,\"replaceDeveloperRoleWithSystem\":false}},\"default_kind\":22,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"ordering_weight\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"ORDERING_WEIGHT\"}},\"comment\":\"Ordering weight for display sorting\"},{\"name\":\"error_message\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"remark\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-defined remark or note for the channel\"},{\"name\":\"endpoints\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.ChannelEndpoint\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.ChannelEndpoint\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Outbound API endpoints for this channel. Each endpoint specifies api_format and optional path. When empty, defaults are derived from channel type.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"channels_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ChannelModelPrice\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"channel_model_prices\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"versions\",\"type\":\"ChannelModelPriceVersion\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"price\",\"type\":{\"Type\":3,\"Ident\":\"objects.ModelPrice\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Equals\":{\"In\":[{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"Validate\":{\"In\":[],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The model price, if changed, it will genearte a new reference id.\"},{\"name\":\"reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The bill should reference this id.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"channel_id\",\"model_id\",\"deleted_at\"],\"storage_key\":\"channel_model_prices_by_channel_id_model_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"ChannelModelPriceVersion\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel_model_price\",\"type\":\"ChannelModelPrice\",\"field\":\"channel_model_price_id\",\"ref_name\":\"versions\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"channel_model_price_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"price\",\"type\":{\"Type\":3,\"Ident\":\"objects.ModelPrice\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Equals\":{\"In\":[{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"Validate\":{\"In\":[],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The model price, if changed, it will genearte a new reference id.\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"channelmodelpriceversion.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"effective_start_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The effective start time of the model price.\"},{\"name\":\"effective_end_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The effective end time of the model price, null means it is effective until the next version.\"},{\"name\":\"reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The bill should reference this id.\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"ChannelOverrideTemplate\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"channel_override_templates\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Owner of this template\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template name, unique per user\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template description\"},{\"name\":\"override_parameters\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Override request body parameters as JSON string\",\"deprecated\":true,\"deprecated_reason\":\"Use body_override_operations instead\"},{\"name\":\"override_headers\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.HeaderEntry\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.HeaderEntry\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Override request headers\",\"deprecated\":true},{\"name\":\"header_override_operations\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.OverrideOperation\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.OverrideOperation\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}},\"comment\":\"Override request headers\"},{\"name\":\"body_override_operations\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.OverrideOperation\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.OverrideOperation\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}},\"comment\":\"Override request body parameters\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"name\",\"deleted_at\"],\"storage_key\":\"channel_override_templates_by_user_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ChannelProbe\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"channel_probes\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"total_request_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"success_request_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avg_tokens_per_second\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avg_time_to_first_token_ms\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"timestamp\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"fields\":[\"channel_id\",\"timestamp\"],\"storage_key\":\"channel_probes_by_channel_id_timestamp\"}]},{\"name\":\"DataStorage\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source description\"},{\"name\":\"primary\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"data source is primary, only the system database is the primary, it can not be archived.\"},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"datastorage.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"database\",\"V\":\"database\"},{\"N\":\"fs\",\"V\":\"fs\"},{\"N\":\"s3\",\"V\":\"s3\"},{\"N\":\"gcs\",\"V\":\"gcs\"},{\"N\":\"webdav\",\"V\":\"webdav\"}],\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source type\"},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.DataStorageSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"DataStorageSettings\",\"Ident\":\"objects.DataStorageSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source setting\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"datastorage.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source status\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\"],\"storage_key\":\"data_sources_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Invitation\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"invitations\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"token_hash\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"role_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"expires_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"max_uses\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"used_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"token_hash\"]},{\"fields\":[\"project_id\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"Model\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"developer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"developer of the model, eg. deeepseek\"},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model id, eg. deeepseek-chat\"},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"model.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"chat\",\"V\":\"chat\"},{\"N\":\"embedding\",\"V\":\"embedding\"},{\"N\":\"rerank\",\"V\":\"rerank\"},{\"N\":\"image_generation\",\"V\":\"image_generation\"},{\"N\":\"video_generation\",\"V\":\"video_generation\"}],\"default\":true,\"default_value\":\"chat\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model type\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}},\"comment\":\"model name, eg. DeepSeek Chat\"},{\"name\":\"icon\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"icon of the model from the lobe-icons, eg. DeepSeek\"},{\"name\":\"group\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model group, eg. deepseek\"},{\"name\":\"model_card\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ModelCard\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ModelCard\",\"Ident\":\"objects.ModelCard\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ModelSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ModelSettings\",\"Ident\":\"objects.ModelSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"NormalizeRoutingPolicy\":{\"In\":[],\"Out\":[]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"model.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"remark\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-defined remark or note for the Model\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"models_by_name\"},{\"unique\":true,\"fields\":[\"model_id\",\"deleted_at\"],\"storage_key\":\"models_by_model_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"OIDCIdentity\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48},\"EntSQL\":{\"on_delete\":\"CASCADE\"}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"issuer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"OIDC provider issuer URL\"},{\"name\":\"subject\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"OIDC subject identifier\"},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Email from OIDC provider\"},{\"name\":\"idp_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Identity provider name\"},{\"name\":\"last_login_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Last login timestamp\"},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reference to the User entity\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"issuer\",\"subject\",\"deleted_at\"],\"storage_key\":\"oidc_identities_by_issuer_subject_deleted_at\"},{\"fields\":[\"user_id\"],\"storage_key\":\"oidc_identities_by_user_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Project\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\",\"through\":{\"N\":\"project_users\",\"T\":\"UserProject\"},\"storage_key\":{\"Table\":\"\",\"Symbols\":[\"user_projects_by_user_id_project_id\"],\"Columns\":null},\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"invitations\",\"type\":\"Invitation\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"roles\",\"type\":\"Role\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"api_keys\",\"type\":\"APIKey\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"threads\",\"type\":\"Thread\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"traces\",\"type\":\"Trace\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"prompts\",\"type\":\"Prompt\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"api_key_profile_templates\",\"type\":\"APIKeyProfileTemplate\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project description\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"project.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project status\"},{\"name\":\"profiles\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ProjectProfiles\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ProjectProfiles\",\"Ident\":\"objects.ProjectProfiles\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"activeProfile\":\"\",\"profiles\":null},\"default_kind\":22,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"projects_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Prompt\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"prompts\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Project ID that this prompt belongs to\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt description\"},{\"name\":\"role\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt role\"},{\"name\":\"content\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"longtext\"},\"comment\":\"prompt content\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"prompt.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"order\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"ORDER\"}},\"comment\":\"prompt insertion order, smaller values are inserted first\"},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"objects.PromptSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"PromptSettings\",\"Ident\":\"objects.PromptSettings\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt settings in JSON format\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"prompts_by_project_id\"},{\"unique\":true,\"fields\":[\"project_id\",\"name\",\"deleted_at\"],\"storage_key\":\"prompts_by_project_id_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"PromptProtectionRule\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}},\"comment\":\"Rule name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Rule description\"},{\"name\":\"pattern\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Regex pattern to match prompt content\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"promptprotectionrule.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.PromptProtectionSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"PromptProtectionSettings\",\"Ident\":\"objects.PromptProtectionSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Prompt protection rule settings\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"prompt_protection_rules_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ProviderQuotaStatus\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"provider_quota_status\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"provider_type\",\"type\":{\"Type\":6,\"Ident\":\"providerquotastatus.ProviderType\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"claudecode\",\"V\":\"claudecode\"},{\"N\":\"codex\",\"V\":\"codex\"},{\"N\":\"github_copilot\",\"V\":\"github_copilot\"},{\"N\":\"nanogpt\",\"V\":\"nanogpt\"},{\"N\":\"cline\",\"V\":\"cline\"},{\"N\":\"wafer\",\"V\":\"wafer\"},{\"N\":\"synthetic\",\"V\":\"synthetic\"},{\"N\":\"neuralwatt\",\"V\":\"neuralwatt\"},{\"N\":\"apertis\",\"V\":\"apertis\"},{\"N\":\"opencode_go\",\"V\":\"opencode_go\"},{\"N\":\"kimi_code\",\"V\":\"kimi_code\"},{\"N\":\"minimax\",\"V\":\"minimax\"},{\"N\":\"zhipu\",\"V\":\"zhipu\"}],\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"providerquotastatus.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"available\",\"V\":\"available\"},{\"N\":\"warning\",\"V\":\"warning\"},{\"N\":\"exhausted\",\"V\":\"exhausted\"},{\"N\":\"unknown\",\"V\":\"unknown\"}],\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Overall status: available, warning, exhausted, unknown\"},{\"name\":\"quota_data\",\"type\":{\"Type\":3,\"Ident\":\"map[string]interface {}\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"map[string]interface {}\",\"Kind\":21,\"PkgPath\":\"\",\"Methods\":{}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Provider-specific quota data\"},{\"name\":\"next_reset_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Timestamp for next quota reset (primary window)\"},{\"name\":\"ready\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":true,\"default_kind\":1,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"True if status is available or warning\"},{\"name\":\"next_check_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Timestamp for next scheduled quota check\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"channel_id\"]},{\"fields\":[\"next_check_at\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}]},{\"name\":\"Request\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"api_key\",\"type\":\"APIKey\",\"field\":\"api_key_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"trace\",\"type\":\"Trace\",\"field\":\"trace_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"data_storage\",\"type\":\"DataStorage\",\"field\":\"data_storage_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"api_key_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"API Key ID of the request, null for the request from the Admin.\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"trace_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Trace ID that this request belongs to\"},{\"name\":\"data_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Data Storage ID that this request belongs to\"},{\"name\":\"source\",\"type\":{\"Type\":6,\"Ident\":\"request.Source\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"api\",\"V\":\"api\"},{\"N\":\"playground\",\"V\":\"playground\"},{\"N\":\"test\",\"V\":\"test\"}],\"default\":true,\"default_value\":\"api\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"reasoning_effort\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning effort used for reasoning models\"},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_headers\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request headers\"},{\"name\":\"request_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_chunks\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"external_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":512,\"optional\":true,\"validators\":1,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"request.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"completed\",\"V\":\"completed\"},{\"N\":\"failed\",\"V\":\"failed\"},{\"N\":\"canceled\",\"V\":\"canceled\"}],\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"stream\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"client_ip\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_reasoning_duration_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning/thinking duration in milliseconds\"},{\"name\":\"content_saved\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":20,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"whether the generated content has been saved to external storage\"},{\"name\":\"content_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":21,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data storage id used to save the content file\"},{\"name\":\"content_storage_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":22,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"storage key/path of the saved content file\"},{\"name\":\"content_saved_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":23,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"when the content file was saved\"}],\"indexes\":[{\"fields\":[\"api_key_id\",\"created_at\"],\"storage_key\":\"requests_by_api_key_id_created_at\"},{\"fields\":[\"project_id\",\"created_at\"],\"storage_key\":\"requests_by_project_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"requests_by_channel_id_created_at\"},{\"fields\":[\"trace_id\",\"created_at\"],\"storage_key\":\"requests_by_trace_id_created_at\"},{\"fields\":[\"created_at\"],\"storage_key\":\"requests_by_created_at\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"RequestExecution\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"request\",\"type\":\"Request\",\"field\":\"request_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"data_storage\",\"type\":\"DataStorage\",\"field\":\"data_storage_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"data_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Data Storage ID that this request belongs to\"},{\"name\":\"external_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":512,\"optional\":true,\"validators\":1,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_chunks\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"error_message\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"response_status_code\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"HTTP status code from the upstream provider\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"requestexecution.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"completed\",\"V\":\"completed\"},{\"N\":\"failed\",\"V\":\"failed\"},{\"N\":\"canceled\",\"V\":\"canceled\"}],\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"stream\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_reasoning_duration_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning/thinking duration in milliseconds\"},{\"name\":\"request_headers\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request headers\"},{\"name\":\"request_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Actual upstream request URL sent to the provider\"},{\"name\":\"pass_through_applied\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Whether pass-through was active for this execution attempt\"}],\"indexes\":[{\"fields\":[\"request_id\",\"status\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_status_created_at\"},{\"fields\":[\"request_id\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"request_executions_by_channel_id_created_at\"}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"Role\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\",\"ref_name\":\"roles\",\"through\":{\"N\":\"user_roles\",\"T\":\"UserRole\"},\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"roles\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"level\",\"type\":{\"Type\":6,\"Ident\":\"role.Level\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"system\",\"V\":\"system\"},{\"N\":\"project\",\"V\":\"project\"}],\"default\":true,\"default_value\":\"system\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Role level: system or project\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID for project-level roles, 0 for system roles, it is used to make the role unique in system level.\"},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Available scopes for this role: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"project_id\",\"name\"],\"storage_key\":\"roles_by_project_id_name\"},{\"fields\":[\"level\"],\"storage_key\":\"roles_by_level\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"System\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"value\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"mediumtext\"}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Thread\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"threads\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"traces\",\"type\":\"Trace\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID that this thread belongs to\"},{\"name\":\"thread_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Unique thread identifier for this thread\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"thread.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"},{\"N\":\"retained\",\"V\":\"retained\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Record status: active (default), archived (hidden), retained (protected from GC)\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"threads_by_project_id\"},{\"unique\":true,\"fields\":[\"thread_id\"],\"storage_key\":\"threads_by_thread_id\"},{\"fields\":[\"project_id\",\"status\"],\"storage_key\":\"threads_by_project_id_status\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Trace\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"traces\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"thread\",\"type\":\"Thread\",\"field\":\"thread_id\",\"ref_name\":\"traces\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID that this trace belongs to\"},{\"name\":\"trace_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Unique trace identifier\"},{\"name\":\"thread_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Thread ID that this trace belongs to\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"trace.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"},{\"N\":\"retained\",\"V\":\"retained\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Record status: active (default), archived (hidden), retained (protected from GC)\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"traces_by_project_id\"},{\"unique\":true,\"fields\":[\"trace_id\"],\"storage_key\":\"traces_by_trace_id\"},{\"fields\":[\"thread_id\"],\"storage_key\":\"traces_by_thread_id\"},{\"fields\":[\"project_id\",\"status\"],\"storage_key\":\"traces_by_project_id_status\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"UsageLog\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"request\",\"type\":\"Request\",\"field\":\"request_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"request_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Related request ID\"},{\"name\":\"api_key_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Channel ID used for the request\"},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Model identifier used for the request\"},{\"name\":\"prompt_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of tokens in the prompt\"},{\"name\":\"completion_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of tokens in the completion\"},{\"name\":\"total_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Total number of tokens used\"},{\"name\":\"prompt_audio_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of audio tokens in the prompt\"},{\"name\":\"prompt_cached_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of cached tokens in the prompt\"},{\"name\":\"prompt_write_cached_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of total write cache tokens, if 5m or 1h ttl variant is present, the field is the sum of 5m and 1h\"},{\"name\":\"prompt_write_cached_tokens_5m\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of token write cache with 5m ttl\"},{\"name\":\"prompt_write_cached_tokens_1h\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of token write cache with 1h ttl\"},{\"name\":\"completion_audio_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of audio tokens in the completion\"},{\"name\":\"completion_reasoning_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of reasoning tokens in the completion\"},{\"name\":\"completion_accepted_prediction_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of accepted prediction tokens\"},{\"name\":\"completion_rejected_prediction_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of rejected prediction tokens\"},{\"name\":\"source\",\"type\":{\"Type\":6,\"Ident\":\"usagelog.Source\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"api\",\"V\":\"api\"},{\"N\":\"playground\",\"V\":\"playground\"},{\"N\":\"test\",\"V\":\"test\"}],\"default\":true,\"default_value\":\"api\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Source of the request\"},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request format used\"},{\"name\":\"total_cost\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Total cost calculated based on channel model price\"},{\"name\":\"cost_items\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.CostItem\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.CostItem\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":20,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Detailed cost breakdown items in JSON\"},{\"name\":\"cost_price_reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":21,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reference ID to the channel model price version used for cost calculation\"}],\"indexes\":[{\"fields\":[\"request_id\"],\"storage_key\":\"usage_logs_by_request_id\"},{\"fields\":[\"created_at\"],\"storage_key\":\"usage_logs_by_created_at\"},{\"fields\":[\"model_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_model_id_created_at\"},{\"fields\":[\"project_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_project_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_channel_id_created_at\"},{\"fields\":[\"api_key_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_api_key_id_created_at\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"User\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"projects\",\"type\":\"Project\",\"ref_name\":\"users\",\"through\":{\"N\":\"project_users\",\"T\":\"UserProject\"},\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"api_keys\",\"type\":\"APIKey\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"roles\",\"type\":\"Role\",\"through\":{\"N\":\"user_roles\",\"T\":\"UserRole\"},\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"channel_override_templates\",\"type\":\"ChannelOverrideTemplate\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"oidc_identities\",\"type\":\"OIDCIdentity\",\"ref_name\":\"user\",\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"user.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"activated\",\"V\":\"activated\"},{\"N\":\"deactivated\",\"V\":\"deactivated\"}],\"default\":true,\"default_value\":\"activated\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"prefer_language\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"en\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"用户偏好语言\"},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"first_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"last_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avatar\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"mediumtext\"},\"comment\":\"用户头像URL\"},{\"name\":\"is_owner\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User scopes in system level: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"email\",\"deleted_at\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"UserProject\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"unique\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_owner\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Indicates whether the user is the owner of the project. This field is mutable to allow transferring ownership between users. Only users with sufficient permissions (e.g., current owner) can modify this field.\"},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-specific scopes: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"project_id\"],\"storage_key\":\"user_projects_by_user_id_project_id\"},{\"fields\":[\"project_id\"],\"storage_key\":\"user_projects_by_project_id\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}]},{\"name\":\"UserRole\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"immutable\":true},{\"name\":\"role\",\"type\":\"Role\",\"field\":\"role_id\",\"unique\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"role_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_kind\":19,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"role_id\"],\"storage_key\":\"user_roles_by_user_id_role_id\"},{\"fields\":[\"role_id\"],\"storage_key\":\"user_roles_by_role_id\"}]}],\"Features\":[\"intercept\",\"schema/snapshot\",\"sql/upsert\",\"sql/modifier\",\"entql\",\"privacy\",\"namedges\"]}" +const Schema = "{\"Schema\":\"github.com/looplj/axonhub/internal/ent/schema\",\"Package\":\"github.com/looplj/axonhub/internal/ent\",\"Schemas\":[{\"name\":\"APIKey\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"api_keys\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"api_keys\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":32}}},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"The creator of the API key\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"apikey.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"user\",\"V\":\"user\"},{\"N\":\"service_account\",\"V\":\"service_account\"},{\"N\":\"noauth\",\"V\":\"noauth\"},{\"N\":\"personal\",\"V\":\"personal\"}],\"default\":true,\"default_value\":\"user\",\"default_kind\":24,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"API Key type: user, service_account, noauth, or personal\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"apikey.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"enabled\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[\"read_channels\",\"write_requests\"],\"default_kind\":23,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"API Key specific scopes. For user type: default read_channels, write_requests (immutable). For service_account: custom scopes.\"},{\"name\":\"profiles\",\"type\":{\"Type\":3,\"Ident\":\"*objects.APIKeyProfiles\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"APIKeyProfiles\",\"Ident\":\"objects.APIKeyProfiles\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"activeProfile\":\"\",\"profiles\":null},\"default_kind\":22,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"allowed_ips\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"IP CIDR allowlist for this API key. If non-empty, only requests from matching source IPs are accepted.\"}],\"indexes\":[{\"fields\":[\"user_id\"],\"storage_key\":\"api_keys_by_user_id\"},{\"fields\":[\"project_id\"],\"storage_key\":\"api_keys_by_project_id\"},{\"unique\":true,\"fields\":[\"key\"],\"storage_key\":\"api_keys_by_key\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"APIKeyProfileTemplate\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"api_key_profile_templates\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":32}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template description\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Project ID, set via project edge\"},{\"name\":\"profile\",\"type\":{\"Type\":3,\"Ident\":\"*objects.APIKeyProfile\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"APIKeyProfile\",\"Ident\":\"objects.APIKeyProfile\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Clone\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"*objects.APIKeyProfile\",\"Kind\":22,\"PkgPath\":\"\",\"Methods\":null}]},\"MatchChannelTags\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"default\":true,\"default_value\":{\"modelMappings\":null,\"name\":\"\"},\"default_kind\":22,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"project_id\",\"name\",\"deleted_at\"],\"storage_key\":\"api_key_profile_templates_by_project_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Channel\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"channel_probes\",\"type\":\"ChannelProbe\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"channel_model_prices\",\"type\":\"ChannelModelPrice\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"provider_quota_status\",\"type\":\"ProviderQuotaStatus\",\"unique\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"channel.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"openai\",\"V\":\"openai\"},{\"N\":\"openai_responses\",\"V\":\"openai_responses\"},{\"N\":\"atlascloud\",\"V\":\"atlascloud\"},{\"N\":\"cline\",\"V\":\"cline\"},{\"N\":\"codex\",\"V\":\"codex\"},{\"N\":\"vercel\",\"V\":\"vercel\"},{\"N\":\"anthropic\",\"V\":\"anthropic\"},{\"N\":\"anthropic_aws\",\"V\":\"anthropic_aws\"},{\"N\":\"anthropic_gcp\",\"V\":\"anthropic_gcp\"},{\"N\":\"gemini_openai\",\"V\":\"gemini_openai\"},{\"N\":\"gemini\",\"V\":\"gemini\"},{\"N\":\"gemini_vertex\",\"V\":\"gemini_vertex\"},{\"N\":\"deepseek\",\"V\":\"deepseek\"},{\"N\":\"deepseek_anthropic\",\"V\":\"deepseek_anthropic\"},{\"N\":\"deepinfra\",\"V\":\"deepinfra\"},{\"N\":\"qiniu\",\"V\":\"qiniu\"},{\"N\":\"fireworks\",\"V\":\"fireworks\"},{\"N\":\"doubao\",\"V\":\"doubao\"},{\"N\":\"doubao_anthropic\",\"V\":\"doubao_anthropic\"},{\"N\":\"moonshot\",\"V\":\"moonshot\"},{\"N\":\"moonshot_anthropic\",\"V\":\"moonshot_anthropic\"},{\"N\":\"zhipu\",\"V\":\"zhipu\"},{\"N\":\"zai\",\"V\":\"zai\"},{\"N\":\"zhipu_anthropic\",\"V\":\"zhipu_anthropic\"},{\"N\":\"zai_anthropic\",\"V\":\"zai_anthropic\"},{\"N\":\"anthropic_fake\",\"V\":\"anthropic_fake\"},{\"N\":\"openai_fake\",\"V\":\"openai_fake\"},{\"N\":\"openrouter\",\"V\":\"openrouter\"},{\"N\":\"xiaomi\",\"V\":\"xiaomi\"},{\"N\":\"xiaomi_anthropic\",\"V\":\"xiaomi_anthropic\"},{\"N\":\"xai\",\"V\":\"xai\"},{\"N\":\"ppio\",\"V\":\"ppio\"},{\"N\":\"siliconflow\",\"V\":\"siliconflow\"},{\"N\":\"volcengine\",\"V\":\"volcengine\"},{\"N\":\"volcengine_anthropic\",\"V\":\"volcengine_anthropic\"},{\"N\":\"longcat\",\"V\":\"longcat\"},{\"N\":\"longcat_anthropic\",\"V\":\"longcat_anthropic\"},{\"N\":\"minimax\",\"V\":\"minimax\"},{\"N\":\"minimax_anthropic\",\"V\":\"minimax_anthropic\"},{\"N\":\"aihubmix\",\"V\":\"aihubmix\"},{\"N\":\"aihubmix_anthropic\",\"V\":\"aihubmix_anthropic\"},{\"N\":\"burncloud\",\"V\":\"burncloud\"},{\"N\":\"modelscope\",\"V\":\"modelscope\"},{\"N\":\"bailian\",\"V\":\"bailian\"},{\"N\":\"bailian_anthropic\",\"V\":\"bailian_anthropic\"},{\"N\":\"moonshot_coding\",\"V\":\"moonshot_coding\"},{\"N\":\"jina\",\"V\":\"jina\"},{\"N\":\"github\",\"V\":\"github\"},{\"N\":\"github_copilot\",\"V\":\"github_copilot\"},{\"N\":\"claudecode\",\"V\":\"claudecode\"},{\"N\":\"cerebras\",\"V\":\"cerebras\"},{\"N\":\"antigravity\",\"V\":\"antigravity\"},{\"N\":\"nanogpt\",\"V\":\"nanogpt\"},{\"N\":\"nanogpt_responses\",\"V\":\"nanogpt_responses\"},{\"N\":\"opencode_go\",\"V\":\"opencode_go\"},{\"N\":\"opencode_go_anthropic\",\"V\":\"opencode_go_anthropic\"},{\"N\":\"ollama\",\"V\":\"ollama\"},{\"N\":\"ollama_anthropic\",\"V\":\"ollama_anthropic\"},{\"N\":\"evolink\",\"V\":\"evolink\"},{\"N\":\"evolink_anthropic\",\"V\":\"evolink_anthropic\"}],\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"TYPE\"}}},{\"name\":\"base_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"channel.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"STATUS\",\"Skip\":16}}},{\"name\":\"credentials\",\"type\":{\"Type\":3,\"Ident\":\"objects.ChannelCredentials\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ChannelCredentials\",\"Ident\":\"objects.ChannelCredentials\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"GetAllAPIKeys\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"GetEnabledAPIKeys\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]objects.DisabledAPIKey\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"IsOAuth\":{\"In\":[],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"disabled_api_keys\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.DisabledAPIKey\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.DisabledAPIKey\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Disabled API keys with metadata (sensitive; requires channel write permission)\"},{\"name\":\"supported_models\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"manual_models\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"auto_sync_supported_models\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"auto_sync_model_pattern\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Regex pattern to filter models during auto-sync. Empty string means no filtering.\"},{\"name\":\"tags\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"default_test_model\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"policies\",\"type\":{\"Type\":3,\"Ident\":\"objects.ChannelPolicies\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ChannelPolicies\",\"Ident\":\"objects.ChannelPolicies\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"stream\":\"unlimited\"},\"default_kind\":25,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ChannelSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ChannelSettings\",\"Ident\":\"objects.ChannelSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"autoTrimedModelPrefixes\":null,\"extraModelPrefix\":\"\",\"hideMappedModels\":false,\"hideOriginalModels\":false,\"lowercaseModelId\":false,\"modelMappings\":[],\"overrideHeaders\":null,\"overrideParameters\":\"\",\"transformOptions\":{\"forceArrayInputs\":false,\"forceArrayInstructions\":false,\"replaceDeveloperRoleWithSystem\":false}},\"default_kind\":22,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"ordering_weight\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"ORDERING_WEIGHT\"}},\"comment\":\"Ordering weight for display sorting\"},{\"name\":\"error_message\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"remark\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-defined remark or note for the channel\"},{\"name\":\"endpoints\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.ChannelEndpoint\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.ChannelEndpoint\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Outbound API endpoints for this channel. Each endpoint specifies api_format and optional path. When empty, defaults are derived from channel type.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"channels_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ChannelModelPrice\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"channel_model_prices\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"versions\",\"type\":\"ChannelModelPriceVersion\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"price\",\"type\":{\"Type\":3,\"Ident\":\"objects.ModelPrice\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Equals\":{\"In\":[{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"Validate\":{\"In\":[],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The model price, if changed, it will genearte a new reference id.\"},{\"name\":\"reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The bill should reference this id.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"channel_id\",\"model_id\",\"deleted_at\"],\"storage_key\":\"channel_model_prices_by_channel_id_model_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"ChannelModelPriceVersion\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel_model_price\",\"type\":\"ChannelModelPrice\",\"field\":\"channel_model_price_id\",\"ref_name\":\"versions\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"channel_model_price_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"price\",\"type\":{\"Type\":3,\"Ident\":\"objects.ModelPrice\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Equals\":{\"In\":[{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"Validate\":{\"In\":[],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The model price, if changed, it will genearte a new reference id.\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"channelmodelpriceversion.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"effective_start_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The effective start time of the model price.\"},{\"name\":\"effective_end_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The effective end time of the model price, null means it is effective until the next version.\"},{\"name\":\"reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The bill should reference this id.\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"ChannelOverrideTemplate\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"channel_override_templates\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Owner of this template\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template name, unique per user\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template description\"},{\"name\":\"override_parameters\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Override request body parameters as JSON string\",\"deprecated\":true,\"deprecated_reason\":\"Use body_override_operations instead\"},{\"name\":\"override_headers\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.HeaderEntry\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.HeaderEntry\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Override request headers\",\"deprecated\":true},{\"name\":\"header_override_operations\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.OverrideOperation\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.OverrideOperation\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}},\"comment\":\"Override request headers\"},{\"name\":\"body_override_operations\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.OverrideOperation\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.OverrideOperation\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}},\"comment\":\"Override request body parameters\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"name\",\"deleted_at\"],\"storage_key\":\"channel_override_templates_by_user_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ChannelProbe\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"channel_probes\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"total_request_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"success_request_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avg_tokens_per_second\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avg_time_to_first_token_ms\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"timestamp\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"fields\":[\"channel_id\",\"timestamp\"],\"storage_key\":\"channel_probes_by_channel_id_timestamp\"}]},{\"name\":\"DataStorage\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source description\"},{\"name\":\"primary\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"data source is primary, only the system database is the primary, it can not be archived.\"},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"datastorage.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"database\",\"V\":\"database\"},{\"N\":\"fs\",\"V\":\"fs\"},{\"N\":\"s3\",\"V\":\"s3\"},{\"N\":\"gcs\",\"V\":\"gcs\"},{\"N\":\"webdav\",\"V\":\"webdav\"}],\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source type\"},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.DataStorageSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"DataStorageSettings\",\"Ident\":\"objects.DataStorageSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source setting\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"datastorage.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source status\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\"],\"storage_key\":\"data_sources_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Invitation\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"invitations\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"token_hash\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"role_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"expires_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"max_uses\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"used_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"token_hash\"]},{\"fields\":[\"project_id\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"Model\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"developer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"developer of the model, eg. deeepseek\"},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model id, eg. deeepseek-chat\"},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"model.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"chat\",\"V\":\"chat\"},{\"N\":\"embedding\",\"V\":\"embedding\"},{\"N\":\"rerank\",\"V\":\"rerank\"},{\"N\":\"image_generation\",\"V\":\"image_generation\"},{\"N\":\"video_generation\",\"V\":\"video_generation\"}],\"default\":true,\"default_value\":\"chat\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model type\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}},\"comment\":\"model name, eg. DeepSeek Chat\"},{\"name\":\"icon\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"icon of the model from the lobe-icons, eg. DeepSeek\"},{\"name\":\"group\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model group, eg. deepseek\"},{\"name\":\"model_card\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ModelCard\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ModelCard\",\"Ident\":\"objects.ModelCard\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ModelSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ModelSettings\",\"Ident\":\"objects.ModelSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"NormalizeRoutingPolicy\":{\"In\":[],\"Out\":[]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"model.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"remark\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-defined remark or note for the Model\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"models_by_name\"},{\"unique\":true,\"fields\":[\"model_id\",\"deleted_at\"],\"storage_key\":\"models_by_model_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"OIDCIdentity\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48},\"EntSQL\":{\"on_delete\":\"CASCADE\"}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"issuer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"OIDC provider issuer URL\"},{\"name\":\"subject\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"OIDC subject identifier\"},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Email from OIDC provider\"},{\"name\":\"idp_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Identity provider name\"},{\"name\":\"last_login_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Last login timestamp\"},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reference to the User entity\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"issuer\",\"subject\",\"deleted_at\"],\"storage_key\":\"oidc_identities_by_issuer_subject_deleted_at\"},{\"fields\":[\"user_id\"],\"storage_key\":\"oidc_identities_by_user_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Project\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\",\"through\":{\"N\":\"project_users\",\"T\":\"UserProject\"},\"storage_key\":{\"Table\":\"\",\"Symbols\":[\"user_projects_by_user_id_project_id\"],\"Columns\":null},\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"invitations\",\"type\":\"Invitation\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"roles\",\"type\":\"Role\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"api_keys\",\"type\":\"APIKey\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"threads\",\"type\":\"Thread\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"traces\",\"type\":\"Trace\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"prompts\",\"type\":\"Prompt\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"api_key_profile_templates\",\"type\":\"APIKeyProfileTemplate\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project description\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"project.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project status\"},{\"name\":\"profiles\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ProjectProfiles\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ProjectProfiles\",\"Ident\":\"objects.ProjectProfiles\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"activeProfile\":\"\",\"profiles\":null},\"default_kind\":22,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"projects_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Prompt\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"prompts\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Project ID that this prompt belongs to\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt description\"},{\"name\":\"role\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt role\"},{\"name\":\"content\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"longtext\"},\"comment\":\"prompt content\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"prompt.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"order\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"ORDER\"}},\"comment\":\"prompt insertion order, smaller values are inserted first\"},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"objects.PromptSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"PromptSettings\",\"Ident\":\"objects.PromptSettings\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt settings in JSON format\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"prompts_by_project_id\"},{\"unique\":true,\"fields\":[\"project_id\",\"name\",\"deleted_at\"],\"storage_key\":\"prompts_by_project_id_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"PromptProtectionRule\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}},\"comment\":\"Rule name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Rule description\"},{\"name\":\"pattern\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Regex pattern to match prompt content\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"promptprotectionrule.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.PromptProtectionSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"PromptProtectionSettings\",\"Ident\":\"objects.PromptProtectionSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Prompt protection rule settings\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"prompt_protection_rules_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ProviderQuotaStatus\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"provider_quota_status\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"provider_type\",\"type\":{\"Type\":6,\"Ident\":\"providerquotastatus.ProviderType\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"claudecode\",\"V\":\"claudecode\"},{\"N\":\"codex\",\"V\":\"codex\"},{\"N\":\"github_copilot\",\"V\":\"github_copilot\"},{\"N\":\"nanogpt\",\"V\":\"nanogpt\"},{\"N\":\"cline\",\"V\":\"cline\"},{\"N\":\"wafer\",\"V\":\"wafer\"},{\"N\":\"synthetic\",\"V\":\"synthetic\"},{\"N\":\"neuralwatt\",\"V\":\"neuralwatt\"},{\"N\":\"apertis\",\"V\":\"apertis\"},{\"N\":\"opencode_go\",\"V\":\"opencode_go\"},{\"N\":\"kimi_code\",\"V\":\"kimi_code\"},{\"N\":\"minimax\",\"V\":\"minimax\"},{\"N\":\"zhipu\",\"V\":\"zhipu\"}],\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"providerquotastatus.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"available\",\"V\":\"available\"},{\"N\":\"warning\",\"V\":\"warning\"},{\"N\":\"exhausted\",\"V\":\"exhausted\"},{\"N\":\"unknown\",\"V\":\"unknown\"}],\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Overall status: available, warning, exhausted, unknown\"},{\"name\":\"quota_data\",\"type\":{\"Type\":3,\"Ident\":\"map[string]interface {}\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"map[string]interface {}\",\"Kind\":21,\"PkgPath\":\"\",\"Methods\":{}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Provider-specific quota data\"},{\"name\":\"next_reset_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Timestamp for next quota reset (primary window)\"},{\"name\":\"ready\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":true,\"default_kind\":1,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"True if status is available or warning\"},{\"name\":\"next_check_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Timestamp for next scheduled quota check\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"channel_id\"]},{\"fields\":[\"next_check_at\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}]},{\"name\":\"Request\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"api_key\",\"type\":\"APIKey\",\"field\":\"api_key_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"trace\",\"type\":\"Trace\",\"field\":\"trace_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"data_storage\",\"type\":\"DataStorage\",\"field\":\"data_storage_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"api_key_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"API Key ID of the request, null for the request from the Admin.\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"trace_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Trace ID that this request belongs to\"},{\"name\":\"data_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Data Storage ID that this request belongs to\"},{\"name\":\"source\",\"type\":{\"Type\":6,\"Ident\":\"request.Source\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"api\",\"V\":\"api\"},{\"N\":\"playground\",\"V\":\"playground\"},{\"N\":\"test\",\"V\":\"test\"}],\"default\":true,\"default_value\":\"api\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"reasoning_effort\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning effort used for reasoning models\"},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_headers\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request headers\"},{\"name\":\"request_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_chunks\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"external_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":512,\"optional\":true,\"validators\":1,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"request.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"completed\",\"V\":\"completed\"},{\"N\":\"failed\",\"V\":\"failed\"},{\"N\":\"canceled\",\"V\":\"canceled\"}],\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"stream\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"client_ip\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_reasoning_duration_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning/thinking duration in milliseconds\"},{\"name\":\"content_saved\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":20,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"whether the generated content has been saved to external storage\"},{\"name\":\"content_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":21,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data storage id used to save the content file\"},{\"name\":\"content_storage_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":22,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"storage key/path of the saved content file\"},{\"name\":\"content_saved_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":23,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"when the content file was saved\"}],\"indexes\":[{\"fields\":[\"api_key_id\",\"created_at\"],\"storage_key\":\"requests_by_api_key_id_created_at\"},{\"fields\":[\"project_id\",\"created_at\"],\"storage_key\":\"requests_by_project_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"requests_by_channel_id_created_at\"},{\"fields\":[\"trace_id\",\"created_at\"],\"storage_key\":\"requests_by_trace_id_created_at\"},{\"fields\":[\"project_id\",\"external_id\"],\"storage_key\":\"requests_by_project_id_external_id\"},{\"fields\":[\"created_at\"],\"storage_key\":\"requests_by_created_at\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"RequestExecution\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"request\",\"type\":\"Request\",\"field\":\"request_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"data_storage\",\"type\":\"DataStorage\",\"field\":\"data_storage_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"data_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Data Storage ID that this request belongs to\"},{\"name\":\"external_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":512,\"optional\":true,\"validators\":1,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_chunks\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"error_message\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"response_status_code\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"HTTP status code from the upstream provider\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"requestexecution.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"completed\",\"V\":\"completed\"},{\"N\":\"failed\",\"V\":\"failed\"},{\"N\":\"canceled\",\"V\":\"canceled\"}],\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"stream\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_reasoning_duration_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning/thinking duration in milliseconds\"},{\"name\":\"request_headers\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request headers\"},{\"name\":\"request_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Actual upstream request URL sent to the provider\"},{\"name\":\"pass_through_applied\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Whether pass-through was active for this execution attempt\"}],\"indexes\":[{\"fields\":[\"request_id\",\"status\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_status_created_at\"},{\"fields\":[\"request_id\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"request_executions_by_channel_id_created_at\"}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"Role\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\",\"ref_name\":\"roles\",\"through\":{\"N\":\"user_roles\",\"T\":\"UserRole\"},\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"roles\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"level\",\"type\":{\"Type\":6,\"Ident\":\"role.Level\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"system\",\"V\":\"system\"},{\"N\":\"project\",\"V\":\"project\"}],\"default\":true,\"default_value\":\"system\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Role level: system or project\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID for project-level roles, 0 for system roles, it is used to make the role unique in system level.\"},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Available scopes for this role: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"project_id\",\"name\"],\"storage_key\":\"roles_by_project_id_name\"},{\"fields\":[\"level\"],\"storage_key\":\"roles_by_level\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"System\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"value\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"mediumtext\"}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Thread\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"threads\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"traces\",\"type\":\"Trace\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID that this thread belongs to\"},{\"name\":\"thread_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Unique thread identifier for this thread\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"thread.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"},{\"N\":\"retained\",\"V\":\"retained\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Record status: active (default), archived (hidden), retained (protected from GC)\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"threads_by_project_id\"},{\"unique\":true,\"fields\":[\"thread_id\"],\"storage_key\":\"threads_by_thread_id\"},{\"fields\":[\"project_id\",\"status\"],\"storage_key\":\"threads_by_project_id_status\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Trace\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"traces\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"thread\",\"type\":\"Thread\",\"field\":\"thread_id\",\"ref_name\":\"traces\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID that this trace belongs to\"},{\"name\":\"trace_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Unique trace identifier\"},{\"name\":\"thread_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Thread ID that this trace belongs to\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"trace.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"},{\"N\":\"retained\",\"V\":\"retained\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Record status: active (default), archived (hidden), retained (protected from GC)\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"traces_by_project_id\"},{\"unique\":true,\"fields\":[\"trace_id\"],\"storage_key\":\"traces_by_trace_id\"},{\"fields\":[\"thread_id\"],\"storage_key\":\"traces_by_thread_id\"},{\"fields\":[\"project_id\",\"status\"],\"storage_key\":\"traces_by_project_id_status\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"UsageLog\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"request\",\"type\":\"Request\",\"field\":\"request_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"request_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Related request ID\"},{\"name\":\"api_key_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Channel ID used for the request\"},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Model identifier used for the request\"},{\"name\":\"prompt_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of tokens in the prompt\"},{\"name\":\"completion_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of tokens in the completion\"},{\"name\":\"total_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Total number of tokens used\"},{\"name\":\"prompt_audio_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of audio tokens in the prompt\"},{\"name\":\"prompt_cached_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of cached tokens in the prompt\"},{\"name\":\"prompt_write_cached_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of total write cache tokens, if 5m or 1h ttl variant is present, the field is the sum of 5m and 1h\"},{\"name\":\"prompt_write_cached_tokens_5m\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of token write cache with 5m ttl\"},{\"name\":\"prompt_write_cached_tokens_1h\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of token write cache with 1h ttl\"},{\"name\":\"completion_audio_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of audio tokens in the completion\"},{\"name\":\"completion_reasoning_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of reasoning tokens in the completion\"},{\"name\":\"completion_accepted_prediction_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of accepted prediction tokens\"},{\"name\":\"completion_rejected_prediction_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of rejected prediction tokens\"},{\"name\":\"source\",\"type\":{\"Type\":6,\"Ident\":\"usagelog.Source\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"api\",\"V\":\"api\"},{\"N\":\"playground\",\"V\":\"playground\"},{\"N\":\"test\",\"V\":\"test\"}],\"default\":true,\"default_value\":\"api\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Source of the request\"},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request format used\"},{\"name\":\"total_cost\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Total cost calculated based on channel model price\"},{\"name\":\"cost_items\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.CostItem\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.CostItem\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":20,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Detailed cost breakdown items in JSON\"},{\"name\":\"cost_price_reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":21,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reference ID to the channel model price version used for cost calculation\"}],\"indexes\":[{\"fields\":[\"request_id\"],\"storage_key\":\"usage_logs_by_request_id\"},{\"fields\":[\"created_at\"],\"storage_key\":\"usage_logs_by_created_at\"},{\"fields\":[\"model_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_model_id_created_at\"},{\"fields\":[\"project_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_project_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_channel_id_created_at\"},{\"fields\":[\"api_key_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_api_key_id_created_at\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"User\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"projects\",\"type\":\"Project\",\"ref_name\":\"users\",\"through\":{\"N\":\"project_users\",\"T\":\"UserProject\"},\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"api_keys\",\"type\":\"APIKey\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"roles\",\"type\":\"Role\",\"through\":{\"N\":\"user_roles\",\"T\":\"UserRole\"},\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"channel_override_templates\",\"type\":\"ChannelOverrideTemplate\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"oidc_identities\",\"type\":\"OIDCIdentity\",\"ref_name\":\"user\",\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"user.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"activated\",\"V\":\"activated\"},{\"N\":\"deactivated\",\"V\":\"deactivated\"}],\"default\":true,\"default_value\":\"activated\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"prefer_language\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"en\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"用户偏好语言\"},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"first_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"last_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avatar\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"mediumtext\"},\"comment\":\"用户头像URL\"},{\"name\":\"is_owner\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User scopes in system level: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"email\",\"deleted_at\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"UserProject\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"unique\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_owner\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Indicates whether the user is the owner of the project. This field is mutable to allow transferring ownership between users. Only users with sufficient permissions (e.g., current owner) can modify this field.\"},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-specific scopes: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"project_id\"],\"storage_key\":\"user_projects_by_user_id_project_id\"},{\"fields\":[\"project_id\"],\"storage_key\":\"user_projects_by_project_id\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}]},{\"name\":\"UserRole\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"immutable\":true},{\"name\":\"role\",\"type\":\"Role\",\"field\":\"role_id\",\"unique\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"role_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_kind\":19,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"role_id\"],\"storage_key\":\"user_roles_by_user_id_role_id\"},{\"fields\":[\"role_id\"],\"storage_key\":\"user_roles_by_role_id\"}]}],\"Features\":[\"intercept\",\"schema/snapshot\",\"sql/upsert\",\"sql/modifier\",\"entql\",\"privacy\",\"namedges\"]}" diff --git a/internal/ent/migrate/schema.go b/internal/ent/migrate/schema.go index b542e194b..196f40174 100644 --- a/internal/ent/migrate/schema.go +++ b/internal/ent/migrate/schema.go @@ -614,6 +614,11 @@ var ( Unique: false, Columns: []*schema.Column{RequestsColumns[26], RequestsColumns[1]}, }, + { + Name: "requests_by_project_id_external_id", + Unique: false, + Columns: []*schema.Column{RequestsColumns[25], RequestsColumns[11]}, + }, { Name: "requests_by_created_at", Unique: false, diff --git a/internal/ent/schema/request.go b/internal/ent/schema/request.go index bb6378190..670bad1a9 100644 --- a/internal/ent/schema/request.go +++ b/internal/ent/schema/request.go @@ -32,6 +32,8 @@ func (Request) Indexes() []ent.Index { StorageKey("requests_by_channel_id_created_at"), index.Fields("trace_id", "created_at"). StorageKey("requests_by_trace_id_created_at"), + index.Fields("project_id", "external_id"). + StorageKey("requests_by_project_id_external_id"), // Performance indexes for dashboard queries index.Fields("created_at"). StorageKey("requests_by_created_at"), diff --git a/internal/server/biz/data_storage.go b/internal/server/biz/data_storage.go index 7a342be47..a8ba7b493 100644 --- a/internal/server/biz/data_storage.go +++ b/internal/server/biz/data_storage.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "os" "path/filepath" @@ -39,6 +40,8 @@ import ( "github.com/looplj/axonhub/internal/server/scheduler" ) +var errDataExceedsLimit = errors.New("stored data exceeds byte limit") + // DataStorageService handles data storage operations. type DataStorageService struct { *AbstractService @@ -732,6 +735,87 @@ func (s *DataStorageService) LoadData(ctx context.Context, ds *ent.DataStorage, } } +// LoadDataLimited loads at most maxBytes without materializing an oversized +// external object. It checks advertised size first and also caps the reader so +// incorrect or missing size metadata cannot bypass the limit. +func (s *DataStorageService) LoadDataLimited( + ctx context.Context, + ds *ent.DataStorage, + key string, + maxBytes int64, +) ([]byte, error) { + if maxBytes < 0 { + return nil, errDataExceedsLimit + } + + switch ds.Type { + case datastorage.TypeDatabase: + if int64(len(key)) > maxBytes { + return nil, errDataExceedsLimit + } + return []byte(key), nil + case datastorage.TypeFs, datastorage.TypeS3, datastorage.TypeGcs, datastorage.TypeWebdav: + if store, ok, err := s.objectStoreFor(ctx, ds); err != nil { + return nil, err + } else if ok { + body, size, err := store.OpenObject(ctx, normalizeObjectKey(key)) + if err != nil { + return nil, err + } + defer body.Close() + + return readDataLimited(body, size, maxBytes) + } + + fs, err := s.GetFileSystem(ctx, ds) + if err != nil { + return nil, fmt.Errorf("failed to get file system: %w", err) + } + + if ds.Type == datastorage.TypeFs { + key = filepath.FromSlash(key) + } else if ds.Type == datastorage.TypeWebdav || isS3PathStyle(ds) { + key = strings.TrimPrefix(key, "/") + } + + file, err := fs.Open(key) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("failed to stat file: %w", err) + } + + return readDataLimited(file, info.Size(), maxBytes) + default: + return nil, fmt.Errorf("unsupported storage type: %s", ds.Type) + } +} + +// readDataLimited caps allocation even when advertisedSize is absent or wrong. +func readDataLimited(reader io.Reader, advertisedSize, maxBytes int64) ([]byte, error) { + if advertisedSize > maxBytes { + return nil, errDataExceedsLimit + } + + readLimit := maxBytes + if readLimit < math.MaxInt64 { + readLimit++ + } + data, err := io.ReadAll(io.LimitReader(reader, readLimit)) + if err != nil { + return nil, err + } + if int64(len(data)) > maxBytes { + return nil, errDataExceedsLimit + } + + return data, nil +} + // isS3Provided checks if any S3 field is provided in the input (non-empty). func isS3Provided(s3 *objects.S3) bool { if s3 == nil { diff --git a/internal/server/biz/data_storage_limited_test.go b/internal/server/biz/data_storage_limited_test.go new file mode 100644 index 000000000..3b78ac156 --- /dev/null +++ b/internal/server/biz/data_storage_limited_test.go @@ -0,0 +1,121 @@ +package biz + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/ent" + "github.com/looplj/axonhub/internal/ent/datastorage" + "github.com/looplj/axonhub/internal/ent/enttest" + "github.com/looplj/axonhub/internal/ent/request" + "github.com/looplj/axonhub/internal/objects" + "github.com/looplj/axonhub/internal/pkg/xcache" + "github.com/looplj/axonhub/llm" +) + +type unexpectedReadReader struct{} + +func (unexpectedReadReader) Read([]byte) (int, error) { + return 0, errors.New("reader should not be consumed") +} + +func TestReadDataLimited_RejectsAdvertisedAndObservedOversize(t *testing.T) { + _, err := readDataLimited(unexpectedReadReader{}, 5, 4) + require.ErrorIs(t, err, errDataExceedsLimit) + + _, err = readDataLimited(&repeatingReader{remaining: 5}, 1, 4) + require.ErrorIs(t, err, errDataExceedsLimit) +} + +func TestDataStorageService_LoadDataLimited_FileSystem(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "body.json", []byte(`{"ok":true}`), 0o600)) + + service := &DataStorageService{fsCache: map[int]afero.Fs{7: fs}} + storage := &ent.DataStorage{ID: 7, Type: datastorage.TypeFs} + + data, err := service.LoadDataLimited(t.Context(), storage, "body.json", int64(len(`{"ok":true}`))) + require.NoError(t, err) + require.JSONEq(t, `{"ok":true}`, string(data)) + + data, err = service.LoadDataLimited(t.Context(), storage, "body.json", int64(len(`{"ok":true}`)-1)) + require.Nil(t, data) + require.ErrorIs(t, err, errDataExceedsLimit) +} + +func TestRequestService_LoadCompletedResponseExchangeLimitsExternalBodies(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:responses_history_external_limit?mode=memory&_fk=0") + ctx := ent.NewContext(authz.WithTestBypass(t.Context()), client) + storage, err := client.DataStorage.Create(). + SetName("history-fs"). + SetDescription("history external storage"). + SetPrimary(false). + SetType(datastorage.TypeFs). + SetSettings(&objects.DataStorageSettings{}). + Save(ctx) + require.NoError(t, err) + stored, err := client.Request.Create(). + SetProjectID(1). + SetDataStorageID(storage.ID). + SetModelID("gpt-5.5"). + SetFormat(llm.APIFormatOpenAIResponse.String()). + SetSource(request.SourceAPI). + SetStatus(request.StatusCompleted). + SetStream(false). + SetRequestBody(objects.JSONRawMessage(`{}`)). + SetResponseBody(objects.JSONRawMessage(`{}`)). + SetExternalID("resp_external"). + Save(ctx) + require.NoError(t, err) + + requestBody := []byte(`{"model":"gpt-5.5","input":"external"}`) + responseBody := []byte(`{"id":"resp_external","model":"gpt-5.5","output":[]}`) + fs := afero.NewMemMapFs() + requestKey := GenerateRequestBodyKey(stored.ProjectID, stored.ID) + responseKey := GenerateResponseBodyKey(stored.ProjectID, stored.ID) + require.NoError(t, fs.MkdirAll(filepath.Dir(requestKey), 0o755)) + require.NoError(t, afero.WriteFile(fs, requestKey, requestBody, 0o600)) + require.NoError(t, afero.WriteFile(fs, responseKey, responseBody, 0o600)) + + storageService := &DataStorageService{ + AbstractService: &AbstractService{db: client}, + Cache: xcache.NewFromConfig[ent.DataStorage](xcache.Config{Mode: xcache.ModeMemory}), + fsCache: map[int]afero.Fs{storage.ID: fs}, + objectStoreCache: make(map[int]ObjectStore), + } + requestService := &RequestService{ + AbstractService: &AbstractService{db: client}, + DataStorageService: storageService, + } + + exactBudget := int64(len(requestBody) + len(responseBody)) + exchange, err := requestService.LoadCompletedResponseExchange(ctx, "resp_external", 1, nil, exactBudget) + require.NoError(t, err) + require.Equal(t, objects.JSONRawMessage(requestBody), exchange.RequestBody) + require.Equal(t, objects.JSONRawMessage(responseBody), exchange.ResponseBody) + + exchange, err = requestService.LoadCompletedResponseExchange(ctx, "resp_external", 1, nil, exactBudget-1) + require.Nil(t, exchange) + require.ErrorIs(t, err, ErrStoredResponseExchangeTooLarge) +} + +type repeatingReader struct { + remaining int +} + +func (r *repeatingReader) Read(p []byte) (int, error) { + if r.remaining == 0 { + return 0, nil + } + n := min(len(p), r.remaining) + for i := 0; i < n; i++ { + p[i] = 'x' + } + r.remaining -= n + return n, nil +} diff --git a/internal/server/biz/request.go b/internal/server/biz/request.go index 271acd23d..de53322a5 100644 --- a/internal/server/biz/request.go +++ b/internal/server/biz/request.go @@ -7,15 +7,19 @@ import ( "encoding/json" "errors" "fmt" + "os" "path/filepath" "strings" "time" + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" "github.com/eko/gocache/lib/v4/store" "github.com/looplj/axonhub/internal/authz" "github.com/looplj/axonhub/internal/contexts" "github.com/looplj/axonhub/internal/ent" + "github.com/looplj/axonhub/internal/ent/predicate" "github.com/looplj/axonhub/internal/ent/request" "github.com/looplj/axonhub/internal/ent/requestexecution" "github.com/looplj/axonhub/internal/log" @@ -37,6 +41,91 @@ type RequestService struct { previousChannelCache xcache.Cache[int] } +// StoredResponseExchange contains one persisted client request and its completed response. +type StoredResponseExchange struct { + RequestBody objects.JSONRawMessage + ResponseBody objects.JSONRawMessage +} + +// ErrStoredResponseExchangeTooLarge reports that retained response data cannot +// be loaded within the caller's remaining byte budget. +var ErrStoredResponseExchangeTooLarge = errors.New("stored response exchange exceeds byte limit") + +type storedResponseExchangeMetadata struct { + ID int `json:"id"` + ProjectID int `json:"project_id"` + DataStorageID *int `json:"data_storage_id"` + RequestBodyBytes int64 `json:"request_body_bytes"` + ResponseBodyBytes int64 `json:"response_body_bytes"` +} + +// storedResponseBodySizeExpression returns a dialect-specific byte-length +// expression without selecting the JSON body into application memory. +func storedResponseBodySizeExpression(dialectName, column string) string { + switch dialectName { + case dialect.SQLite: + return fmt.Sprintf("COALESCE(LENGTH(CAST(%s AS BLOB)), 0)", column) + case dialect.Postgres: + // Budget the JSON text returned by the driver. The jsonb physical size + // differs from the bytes materialized by Scan. + return fmt.Sprintf("COALESCE(OCTET_LENGTH((%s)::text), 0)", column) + case dialect.MySQL: + // Budget the JSON text returned by the driver. JSON_STORAGE_SIZE reports + // the binary representation instead of the bytes materialized by Scan. + return fmt.Sprintf("COALESCE(OCTET_LENGTH(CAST(%s AS CHAR)), 0)", column) + default: + return fmt.Sprintf("COALESCE(OCTET_LENGTH(CAST(%s AS CHAR)), 0)", column) + } +} + +// storedResponseExchangeFitsBudget checks a combined size without overflowing. +func storedResponseExchangeFitsBudget(requestBytes, responseBytes, maxBytes int64) bool { + return maxBytes >= 0 && requestBytes >= 0 && responseBytes >= 0 && + requestBytes <= maxBytes && responseBytes <= maxBytes-requestBytes +} + +// loadStoredResponseExchangeBody loads one JSON body without hiding external +// storage failures. A missing object represents unavailable retained content; +// other storage errors remain server errors so callers can retry them. +func (s *RequestService) loadStoredResponseExchangeBody( + ctx context.Context, + stored *ent.Request, + databaseBody objects.JSONRawMessage, + externalKey string, + bodyName string, + maxBytes int64, +) (objects.JSONRawMessage, error) { + dataStorage, err := s.getDataStorage(ctx, stored.DataStorageID) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s storage: %w", bodyName, err) + } + if !s.shouldUseExternalStorage(ctx, dataStorage) { + if databaseBody == nil { + return xjson.EmptyJSONRawMessage, nil + } + if int64(len(databaseBody)) > maxBytes { + return nil, ErrStoredResponseExchangeTooLarge + } + return databaseBody, nil + } + + data, err := s.DataStorageService.LoadDataLimited(ctx, dataStorage, externalKey, maxBytes) + if err != nil { + if errors.Is(err, errDataExceedsLimit) { + return nil, ErrStoredResponseExchangeTooLarge + } + if errors.Is(err, os.ErrNotExist) { + return xjson.EmptyJSONRawMessage, nil + } + return nil, fmt.Errorf("failed to load %s from external storage: %w", bodyName, err) + } + if !json.Valid(data) { + return nil, fmt.Errorf("stored %s contains invalid JSON", bodyName) + } + + return objects.JSONRawMessage(data), nil +} + // NewRequestService creates a new RequestService. func NewRequestService( ent *ent.Client, @@ -1212,6 +1301,139 @@ func (s *RequestService) LoadResponseBody(ctx context.Context, req *ent.Request) return xjson.EmptyJSONRawMessage, nil } +// LoadCompletedResponseExchange finds one Responses request by response ID within +// the caller's project and API-key scope, then loads both persisted bodies. +func (s *RequestService) LoadCompletedResponseExchange( + ctx context.Context, + externalID string, + projectID int, + apiKeyID *int, + maxBytes int64, +) (*StoredResponseExchange, error) { + if strings.TrimSpace(externalID) == "" { + return nil, nil + } + if maxBytes < 0 { + return nil, ErrStoredResponseExchangeTooLarge + } + + predicates := []predicate.Request{ + request.ProjectIDEQ(projectID), + request.ExternalIDEQ(externalID), + request.FormatEQ(llm.APIFormatOpenAIResponse.String()), + request.StatusEQ(request.StatusCompleted), + } + if apiKeyID != nil { + predicates = append(predicates, request.APIKeyIDEQ(*apiKeyID)) + } else { + predicates = append(predicates, request.APIKeyIDIsNil()) + } + + client := s.entFromContext(ctx) + dialectName := client.Driver().Dialect() + var metadataRows []storedResponseExchangeMetadata + err := client.Request.Query(). + Where(predicates...). + Order(ent.Desc(request.FieldCreatedAt)). + Limit(1). + Select(request.FieldID, request.FieldProjectID, request.FieldDataStorageID). + Modify(func(selector *entsql.Selector) { + selector.AppendSelectExprAs( + entsql.Expr(storedResponseBodySizeExpression(dialectName, selector.C(request.FieldRequestBody))), + "request_body_bytes", + ) + selector.AppendSelectExprAs( + entsql.Expr(storedResponseBodySizeExpression(dialectName, selector.C(request.FieldResponseBody))), + "response_body_bytes", + ) + }). + Scan(ctx, &metadataRows) + if err != nil { + return nil, fmt.Errorf("failed to find previous response %q: %w", externalID, err) + } + if len(metadataRows) == 0 { + return nil, nil + } + metadata := metadataRows[0] + dataStorageID := 0 + if metadata.DataStorageID != nil { + dataStorageID = *metadata.DataStorageID + } + stored := &ent.Request{ + ID: metadata.ID, + ProjectID: metadata.ProjectID, + DataStorageID: dataStorageID, + } + + dataStorage, err := s.getDataStorage(ctx, stored.DataStorageID) + if err != nil { + return nil, fmt.Errorf("failed to resolve previous response storage: %w", err) + } + if !s.shouldUseExternalStorage(ctx, dataStorage) { + if !storedResponseExchangeFitsBudget(metadata.RequestBodyBytes, metadata.ResponseBodyBytes, maxBytes) { + return nil, ErrStoredResponseExchangeTooLarge + } + + requestSizeExpression := "" + responseSizeExpression := "" + var bodyRows []struct { + RequestBody objects.JSONRawMessage `json:"request_body"` + ResponseBody objects.JSONRawMessage `json:"response_body"` + } + err = client.Request.Query(). + Where(request.IDEQ(metadata.ID)). + Limit(1). + Select(request.FieldRequestBody, request.FieldResponseBody). + Modify(func(selector *entsql.Selector) { + requestSizeExpression = storedResponseBodySizeExpression(dialectName, selector.C(request.FieldRequestBody)) + responseSizeExpression = storedResponseBodySizeExpression(dialectName, selector.C(request.FieldResponseBody)) + selector.Where(entsql.P(func(builder *entsql.Builder) { + builder.WriteString("("). + WriteString(requestSizeExpression). + WriteOp(entsql.OpAdd). + WriteString(responseSizeExpression). + WriteString(")"). + WriteOp(entsql.OpLTE). + Arg(maxBytes) + })) + }). + Scan(ctx, &bodyRows) + if err != nil { + return nil, fmt.Errorf("failed to load previous response %q database bodies: %w", externalID, err) + } + if len(bodyRows) == 0 { + return nil, ErrStoredResponseExchangeTooLarge + } + stored.RequestBody = bodyRows[0].RequestBody + stored.ResponseBody = bodyRows[0].ResponseBody + } + + requestBody, err := s.loadStoredResponseExchangeBody( + ctx, + stored, + stored.RequestBody, + GenerateRequestBodyKey(stored.ProjectID, stored.ID), + "previous response request body", + maxBytes, + ) + if err != nil { + return nil, fmt.Errorf("failed to load previous response request %q: %w", externalID, err) + } + responseBody, err := s.loadStoredResponseExchangeBody( + ctx, + stored, + stored.ResponseBody, + GenerateResponseBodyKey(stored.ProjectID, stored.ID), + "previous response body", + maxBytes-int64(len(requestBody)), + ) + if err != nil { + return nil, fmt.Errorf("failed to load previous response body %q: %w", externalID, err) + } + + return &StoredResponseExchange{RequestBody: requestBody, ResponseBody: responseBody}, nil +} + // LoadResponseChunks returns the request response chunks, loading from external storage when necessary. func (s *RequestService) LoadResponseChunks(ctx context.Context, req *ent.Request) ([]objects.JSONRawMessage, error) { if req == nil { diff --git a/internal/server/orchestrator/outbound.go b/internal/server/orchestrator/outbound.go index 20af8f4fd..3abd1ba50 100644 --- a/internal/server/orchestrator/outbound.go +++ b/internal/server/orchestrator/outbound.go @@ -384,6 +384,16 @@ func (p *PersistentOutboundTransformer) TransformRequest(ctx context.Context, ll llmRequest.Model = entry.ActualModel + if isResponsesFormat(llmRequest.APIFormat) && + p.wrapped.APIFormat() == llm.APIFormatOpenAIChatCompletion && + llmRequest.PreviousResponseID != nil { + hydrated, err := hydratePreviousResponsesForChat(ctx, llmRequest, p.state) + if err != nil { + return nil, err + } + llmRequest = hydrated + } + // Apply channel transform options to create a new request llmRequest = applyTransformOptions(llmRequest, candidate.Channel.Settings) llmRequest = filterResponseCustomToolMessagesForNonResponsesOutbound(llmRequest, p.wrapped.APIFormat()) @@ -415,7 +425,10 @@ func filterResponseCustomToolMessagesForNonResponsesOutbound( return nil } - if !isResponsesFormat(llmRequest.APIFormat) || isResponsesFormat(outboundFormat) || !containsResponseCustomToolMessages(llmRequest.Messages) { + if !isResponsesFormat(llmRequest.APIFormat) || + isResponsesFormat(outboundFormat) || + outboundFormat == llm.APIFormatOpenAIChatCompletion || + !containsResponseCustomToolMessages(llmRequest.Messages) { return llmRequest } diff --git a/internal/server/orchestrator/outbound_test.go b/internal/server/orchestrator/outbound_test.go index 8f38e87ca..d798fdadb 100644 --- a/internal/server/orchestrator/outbound_test.go +++ b/internal/server/orchestrator/outbound_test.go @@ -31,9 +31,11 @@ type mockTransformer struct { aggregatedMeta llm.ResponseMeta aggregatedErr error apiFormat llm.APIFormat + capturedRequest *llm.Request } func (m *mockTransformer) TransformRequest(ctx context.Context, req *llm.Request) (*httpclient.Request, error) { + m.capturedRequest = req body, err := json.Marshal(map[string]any{ "model": req.Model, "messages": req.Messages, @@ -981,8 +983,13 @@ func TestFilterResponseCustomToolMessagesForNonResponsesOutbound(t *testing.T) { }, } - t.Run("filters when inbound is responses and outbound is not", func(t *testing.T) { + t.Run("preserves when outbound Chat adapter supports custom lifecycle", func(t *testing.T) { got := filterResponseCustomToolMessagesForNonResponsesOutbound(baseRequest, llm.APIFormatOpenAIChatCompletion) + require.Same(t, baseRequest, got) + }) + + t.Run("filters when outbound has no custom lifecycle adapter", func(t *testing.T) { + got := filterResponseCustomToolMessagesForNonResponsesOutbound(baseRequest, llm.APIFormatAnthropicMessage) require.NotSame(t, baseRequest, got) require.Len(t, got.Messages, 2) require.Len(t, got.Messages[0].ToolCalls, 1) diff --git a/internal/server/orchestrator/responses_history.go b/internal/server/orchestrator/responses_history.go new file mode 100644 index 000000000..6f6d568cd --- /dev/null +++ b/internal/server/orchestrator/responses_history.go @@ -0,0 +1,188 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/looplj/axonhub/internal/server/biz" + "github.com/looplj/axonhub/llm" + "github.com/looplj/axonhub/llm/httpclient" + "github.com/looplj/axonhub/llm/transformer" + responsesapi "github.com/looplj/axonhub/llm/transformer/openai/responses" +) + +const ( + maxPreviousResponseChainDepth = 1024 + maxPreviousResponseHistoryBytes int64 = 32 * 1024 * 1024 +) + +type previousResponseExchangeLoader interface { + LoadCompletedResponseExchange(context.Context, string, int, *int, int64) (*biz.StoredResponseExchange, error) +} + +type storedResponseRequestState struct { + Instructions string `json:"instructions"` + PreviousResponseID *string `json:"previous_response_id"` +} + +// nextPreviousResponseHistoryBytes safely adds one retained exchange to the +// cumulative history size without allowing the configured limit to overflow. +func nextPreviousResponseHistoryBytes(total int64, requestBytes, responseBytes int) (int64, bool) { + remaining := maxPreviousResponseHistoryBytes - total + if int64(requestBytes) > remaining { + return total, false + } + remaining -= int64(requestBytes) + if int64(responseBytes) > remaining { + return total, false + } + return total + int64(requestBytes) + int64(responseBytes), true +} + +// hydratePreviousResponsesForChat expands server-side Responses history into +// explicit messages because Chat Completions cannot consume previous_response_id. +func hydratePreviousResponsesForChat( + ctx context.Context, + request *llm.Request, + state *PersistenceState, +) (*llm.Request, error) { + if request == nil || request.PreviousResponseID == nil || strings.TrimSpace(*request.PreviousResponseID) == "" { + return request, nil + } + if state == nil || state.Request == nil || state.RequestService == nil { + return nil, fmt.Errorf("%w: previous_response_id history storage is unavailable", transformer.ErrInvalidRequest) + } + + var apiKeyID *int + if state.APIKey != nil { + apiKeyID = &state.APIKey.ID + } + + history, err := loadPreviousResponsesHistory( + ctx, + state.RequestService, + *request.PreviousResponseID, + state.Request.ProjectID, + apiKeyID, + ) + if err != nil { + return nil, err + } + + hydrated := *request + hydrated.Messages = append(history, request.Messages...) + hydrated.PreviousResponseID = nil + + return &hydrated, nil +} + +// loadPreviousResponsesHistory walks a bounded previous_response_id chain and +// converts each retained Responses exchange into chronological Chat messages. +func loadPreviousResponsesHistory( + ctx context.Context, + loader previousResponseExchangeLoader, + responseID string, + projectID int, + apiKeyID *int, +) ([]llm.Message, error) { + type historySegment struct { + messages []llm.Message + } + + inbound := responsesapi.NewInboundTransformer() + segments := make([]historySegment, 0, 4) + visited := make(map[string]struct{}) + currentID := responseID + var historyBytes int64 + + for depth := 0; currentID != ""; depth++ { + if depth >= maxPreviousResponseChainDepth { + return nil, fmt.Errorf("%w: previous_response_id chain exceeds %d responses", transformer.ErrInvalidRequest, maxPreviousResponseChainDepth) + } + if _, exists := visited[currentID]; exists { + return nil, fmt.Errorf("%w: previous_response_id chain contains a cycle at %q", transformer.ErrInvalidRequest, currentID) + } + visited[currentID] = struct{}{} + + remainingBytes := maxPreviousResponseHistoryBytes - historyBytes + exchange, err := loader.LoadCompletedResponseExchange(ctx, currentID, projectID, apiKeyID, remainingBytes) + if err != nil { + if errors.Is(err, biz.ErrStoredResponseExchangeTooLarge) { + return nil, fmt.Errorf("%w: previous_response_id history exceeds %d bytes", transformer.ErrInvalidRequest, maxPreviousResponseHistoryBytes) + } + return nil, fmt.Errorf("failed to load previous response %q: %w", currentID, err) + } + if exchange == nil { + return nil, fmt.Errorf("%w: previous response %q was not found in the current project and API-key scope", transformer.ErrInvalidRequest, currentID) + } + nextHistoryBytes, ok := nextPreviousResponseHistoryBytes(historyBytes, len(exchange.RequestBody), len(exchange.ResponseBody)) + if !ok { + return nil, fmt.Errorf("%w: previous_response_id history exceeds %d bytes", transformer.ErrInvalidRequest, maxPreviousResponseHistoryBytes) + } + historyBytes = nextHistoryBytes + + var requestState storedResponseRequestState + if err := json.Unmarshal(exchange.RequestBody, &requestState); err != nil { + return nil, fmt.Errorf("%w: stored request for previous response %q is invalid: %v", transformer.ErrInvalidRequest, currentID, err) + } + + previousRequest, err := inbound.TransformRequest(ctx, &httpclient.Request{Body: exchange.RequestBody}) + if err != nil { + return nil, fmt.Errorf("%w: stored request for previous response %q is unavailable or invalid: %v", transformer.ErrInvalidRequest, currentID, err) + } + // Responses top-level instructions apply only to their own generation and + // are not inherited through previous_response_id. The inbound transformer + // represents them as the first system message, so omit that synthetic entry + // while retaining explicit system/developer items from input. + if requestState.Instructions != "" { + if len(previousRequest.Messages) == 0 || previousRequest.Messages[0].Role != "system" { + return nil, fmt.Errorf("%w: stored request for previous response %q has inconsistent instructions", transformer.ErrInvalidRequest, currentID) + } + previousRequest.Messages = previousRequest.Messages[1:] + } + + var previousResponse responsesapi.Response + if err := json.Unmarshal(exchange.ResponseBody, &previousResponse); err != nil { + return nil, fmt.Errorf("%w: stored previous response %q is invalid: %v", transformer.ErrInvalidRequest, currentID, err) + } + if previousResponse.ID != currentID { + return nil, fmt.Errorf("%w: stored previous response ID %q does not match requested ID %q", transformer.ErrInvalidRequest, previousResponse.ID, currentID) + } + + responseModel := previousResponse.Model + if responseModel == "" { + responseModel = previousRequest.Model + } + syntheticBody, err := json.Marshal(struct { + Model string `json:"model"` + Input []responsesapi.Item `json:"input"` + }{Model: responseModel, Input: previousResponse.Output}) + if err != nil { + return nil, fmt.Errorf("failed to encode previous response %q history: %w", currentID, err) + } + previousOutput, err := inbound.TransformRequest(ctx, &httpclient.Request{Body: syntheticBody}) + if err != nil { + return nil, fmt.Errorf("%w: stored previous response %q output is invalid: %v", transformer.ErrInvalidRequest, currentID, err) + } + + segmentMessages := make([]llm.Message, 0, len(previousRequest.Messages)+len(previousOutput.Messages)) + segmentMessages = append(segmentMessages, previousRequest.Messages...) + segmentMessages = append(segmentMessages, previousOutput.Messages...) + segments = append(segments, historySegment{messages: segmentMessages}) + + currentID = "" + if requestState.PreviousResponseID != nil { + currentID = strings.TrimSpace(*requestState.PreviousResponseID) + } + } + + history := make([]llm.Message, 0) + for i := len(segments) - 1; i >= 0; i-- { + history = append(history, segments[i].messages...) + } + + return history, nil +} diff --git a/internal/server/orchestrator/responses_history_test.go b/internal/server/orchestrator/responses_history_test.go new file mode 100644 index 000000000..46628adb6 --- /dev/null +++ b/internal/server/orchestrator/responses_history_test.go @@ -0,0 +1,449 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/require" + + "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/ent" + "github.com/looplj/axonhub/internal/ent/datastorage" + "github.com/looplj/axonhub/internal/ent/enttest" + "github.com/looplj/axonhub/internal/ent/request" + "github.com/looplj/axonhub/internal/objects" + "github.com/looplj/axonhub/internal/server/biz" + "github.com/looplj/axonhub/llm" + "github.com/looplj/axonhub/llm/transformer" +) + +type previousResponseLoadCall struct { + responseID string + projectID int + apiKeyID *int +} + +type mapPreviousResponseLoader struct { + exchanges map[string]*biz.StoredResponseExchange + calls []previousResponseLoadCall + err error +} + +func (l *mapPreviousResponseLoader) LoadCompletedResponseExchange( + _ context.Context, + responseID string, + projectID int, + apiKeyID *int, + maxBytes int64, +) (*biz.StoredResponseExchange, error) { + l.calls = append(l.calls, previousResponseLoadCall{ + responseID: responseID, + projectID: projectID, + apiKeyID: apiKeyID, + }) + if l.err != nil { + return nil, l.err + } + exchange := l.exchanges[responseID] + if exchange != nil && int64(len(exchange.RequestBody)+len(exchange.ResponseBody)) > maxBytes { + return nil, biz.ErrStoredResponseExchangeTooLarge + } + return exchange, nil +} + +func storedResponseExchange(requestBody, responseBody string) *biz.StoredResponseExchange { + return &biz.StoredResponseExchange{ + RequestBody: objects.JSONRawMessage(requestBody), + ResponseBody: objects.JSONRawMessage(responseBody), + } +} + +func paddedJSONBody(t *testing.T, body string, size int) objects.JSONRawMessage { + t.Helper() + require.LessOrEqual(t, len(body), size) + padded := make(objects.JSONRawMessage, size) + copy(padded, body) + for i := len(body); i < len(padded); i++ { + padded[i] = ' ' + } + return padded +} + +func seedPrimaryDatabaseStorage(t *testing.T, ctx context.Context, client *ent.Client) { + t.Helper() + _, err := client.DataStorage.Create(). + SetName("primary-database"). + SetDescription("test primary database storage"). + SetPrimary(true). + SetType(datastorage.TypeDatabase). + SetSettings(&objects.DataStorageSettings{}). + Save(ctx) + require.NoError(t, err) +} + +func TestLoadPreviousResponsesHistory_PreservesToolPrimitivesAndOmitsHistoricalInstructions(t *testing.T) { + apiKeyID := 17 + loader := &mapPreviousResponseLoader{exchanges: map[string]*biz.StoredResponseExchange{ + "resp_1": storedResponseExchange( + `{ + "model":"gpt-5.5", + "instructions":"old top-level instruction", + "input":[ + {"type":"message","role":"developer","content":[{"type":"input_text","text":"explicit developer input"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"create a file"}]} + ] + }`, + `{ + "id":"resp_1", + "model":"gpt-5.5", + "status":"completed", + "output":[ + {"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"need patch"}],"encrypted_content":"sig_1"}, + {"id":"call_item_1","type":"custom_tool_call","name":"apply_patch","input":"*** Begin Patch\n*** End Patch","call_id":"apply_patch_1","status":"completed"}, + {"id":"call_item_2","type":"tool_search_call","execution":"client","arguments":"{\"query\":\"agent tools\"}","call_id":"search_1","status":"completed"}, + {"id":"call_item_3","type":"function_call","name":"spawn_agent","namespace":"collaboration","arguments":"{}","call_id":"spawn_1","status":"completed"} + ] + }`, + ), + }} + + history, err := loadPreviousResponsesHistory(t.Context(), loader, "resp_1", 9, &apiKeyID) + require.NoError(t, err) + require.Len(t, history, 3) + require.Equal(t, "developer", history[0].Role) + require.Equal(t, "explicit developer input", lo.FromPtr(history[0].Content.Content)) + require.Equal(t, "user", history[1].Role) + require.Equal(t, "create a file", lo.FromPtr(history[1].Content.Content)) + require.Equal(t, "assistant", history[2].Role) + require.Equal(t, "need patch", lo.FromPtr(history[2].ReasoningContent)) + require.Equal(t, "sig_1", lo.FromPtr(history[2].ReasoningSignature)) + require.Len(t, history[2].ToolCalls, 3) + require.Equal(t, "apply_patch_1", history[2].ToolCalls[0].ID) + require.Equal(t, llm.ToolTypeResponsesCustomTool, history[2].ToolCalls[0].Type) + require.Equal(t, "apply_patch", history[2].ToolCalls[0].ResponseCustomToolCall.Name) + require.Equal(t, "*** Begin Patch\n*** End Patch", history[2].ToolCalls[0].ResponseCustomToolCall.Input) + require.Equal(t, "search_1", history[2].ToolCalls[1].ID) + require.Equal(t, llm.ToolTypeResponsesToolSearch, history[2].ToolCalls[1].Type) + require.Equal(t, "client", history[2].ToolCalls[1].ResponseToolSearchCall.Execution) + require.JSONEq(t, `{"query":"agent tools"}`, history[2].ToolCalls[1].ResponseToolSearchCall.Arguments) + require.Equal(t, "spawn_1", history[2].ToolCalls[2].ID) + require.Equal(t, "spawn_agent", history[2].ToolCalls[2].Function.Name) + require.Equal(t, "collaboration", history[2].ToolCalls[2].Function.Namespace) + require.Equal(t, []previousResponseLoadCall{{responseID: "resp_1", projectID: 9, apiKeyID: &apiKeyID}}, loader.calls) +} + +func TestLoadPreviousResponsesHistory_OrdersMultiHopFunctionLifecycle(t *testing.T) { + loader := &mapPreviousResponseLoader{exchanges: map[string]*biz.StoredResponseExchange{ + "resp_1": storedResponseExchange( + `{"model":"gpt-5.5","input":"first question"}`, + `{"id":"resp_1","model":"gpt-5.5","output":[{"type":"function_call","name":"lookup","arguments":"{\"id\":1}","call_id":"call_1","status":"completed"}]}`, + ), + "resp_2": storedResponseExchange( + `{"model":"gpt-5.5","previous_response_id":"resp_1","input":[{"type":"function_call_output","call_id":"call_1","output":"result one"}]}`, + `{"id":"resp_2","model":"gpt-5.5","output":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"second answer","annotations":[]}]}]}`, + ), + }} + + history, err := loadPreviousResponsesHistory(t.Context(), loader, "resp_2", 3, nil) + require.NoError(t, err) + require.Len(t, history, 4) + require.Equal(t, "user", history[0].Role) + require.Equal(t, "first question", lo.FromPtr(history[0].Content.Content)) + require.Equal(t, "assistant", history[1].Role) + require.Len(t, history[1].ToolCalls, 1) + require.Equal(t, "call_1", history[1].ToolCalls[0].ID) + require.Equal(t, "lookup", history[1].ToolCalls[0].Function.Name) + require.Equal(t, "tool", history[2].Role) + require.Equal(t, "call_1", lo.FromPtr(history[2].ToolCallID)) + require.Equal(t, "result one", lo.FromPtr(history[2].Content.Content)) + require.Equal(t, "assistant", history[3].Role) + require.Equal(t, "second answer", lo.FromPtr(history[3].Content.Content)) + require.Equal(t, "resp_2", loader.calls[0].responseID) + require.Equal(t, "resp_1", loader.calls[1].responseID) +} + +func TestLoadPreviousResponsesHistory_RejectsMissingCycleAndMismatchedID(t *testing.T) { + t.Run("missing", func(t *testing.T) { + loader := &mapPreviousResponseLoader{exchanges: map[string]*biz.StoredResponseExchange{}} + _, err := loadPreviousResponsesHistory(t.Context(), loader, "resp_missing", 1, nil) + require.ErrorIs(t, err, transformer.ErrInvalidRequest) + require.ErrorContains(t, err, "was not found") + }) + + t.Run("cycle", func(t *testing.T) { + loader := &mapPreviousResponseLoader{exchanges: map[string]*biz.StoredResponseExchange{ + "resp_a": storedResponseExchange( + `{"model":"gpt-5.5","previous_response_id":"resp_b","input":"a"}`, + `{"id":"resp_a","model":"gpt-5.5","output":[]}`, + ), + "resp_b": storedResponseExchange( + `{"model":"gpt-5.5","previous_response_id":"resp_a","input":"b"}`, + `{"id":"resp_b","model":"gpt-5.5","output":[]}`, + ), + }} + _, err := loadPreviousResponsesHistory(t.Context(), loader, "resp_a", 1, nil) + require.ErrorIs(t, err, transformer.ErrInvalidRequest) + require.ErrorContains(t, err, "contains a cycle") + }) + + t.Run("mismatched response ID", func(t *testing.T) { + loader := &mapPreviousResponseLoader{exchanges: map[string]*biz.StoredResponseExchange{ + "resp_expected": storedResponseExchange( + `{"model":"gpt-5.5","input":"hello"}`, + `{"id":"resp_other","model":"gpt-5.5","output":[]}`, + ), + }} + _, err := loadPreviousResponsesHistory(t.Context(), loader, "resp_expected", 1, nil) + require.ErrorIs(t, err, transformer.ErrInvalidRequest) + require.ErrorContains(t, err, "does not match requested ID") + }) + + t.Run("request body not retained", func(t *testing.T) { + loader := &mapPreviousResponseLoader{exchanges: map[string]*biz.StoredResponseExchange{ + "resp_1": storedResponseExchange( + `{}`, + `{"id":"resp_1","model":"gpt-5.5","output":[]}`, + ), + }} + history, err := loadPreviousResponsesHistory(t.Context(), loader, "resp_1", 1, nil) + require.Nil(t, history) + require.ErrorIs(t, err, transformer.ErrInvalidRequest) + require.ErrorContains(t, err, `stored request for previous response "resp_1" is unavailable or invalid`) + }) +} + +func TestLoadPreviousResponsesHistory_PreservesStorageFailureClassification(t *testing.T) { + storageErr := errors.New("object storage temporarily unavailable") + loader := &mapPreviousResponseLoader{err: storageErr} + + _, err := loadPreviousResponsesHistory(t.Context(), loader, "resp_1", 1, nil) + require.ErrorIs(t, err, storageErr) + require.NotErrorIs(t, err, transformer.ErrInvalidRequest) +} + +func TestNextPreviousResponseHistoryBytes_EnforcesExactLimit(t *testing.T) { + tests := []struct { + name string + total int64 + requestBytes int + responseBytes int + want int64 + ok bool + }{ + {name: "exact limit", total: maxPreviousResponseHistoryBytes - 2, requestBytes: 1, responseBytes: 1, want: maxPreviousResponseHistoryBytes, ok: true}, + {name: "one byte over", total: maxPreviousResponseHistoryBytes, requestBytes: 1, want: maxPreviousResponseHistoryBytes, ok: false}, + {name: "request exceeds", requestBytes: int(maxPreviousResponseHistoryBytes) + 1, ok: false}, + {name: "response exceeds", responseBytes: int(maxPreviousResponseHistoryBytes) + 1, ok: false}, + {name: "response crosses remaining limit", total: maxPreviousResponseHistoryBytes - 1, requestBytes: 1, responseBytes: 1, want: maxPreviousResponseHistoryBytes - 1, ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := nextPreviousResponseHistoryBytes(tt.total, tt.requestBytes, tt.responseBytes) + require.Equal(t, tt.ok, ok) + require.Equal(t, tt.want, got) + }) + } +} + +func TestLoadPreviousResponsesHistory_RejectsCumulativeHistoryOverByteLimit(t *testing.T) { + requestBody := `{"model":"gpt-5.5","previous_response_id":"resp_next","input":"first"}` + responseBody := `{"id":"resp_limit","model":"gpt-5.5","output":[]}` + requestSize := int(maxPreviousResponseHistoryBytes) - len(responseBody) + loader := &mapPreviousResponseLoader{exchanges: map[string]*biz.StoredResponseExchange{ + "resp_limit": { + RequestBody: paddedJSONBody(t, requestBody, requestSize), + ResponseBody: objects.JSONRawMessage(responseBody), + }, + "resp_next": { + RequestBody: objects.JSONRawMessage(`x`), + }, + }} + + history, err := loadPreviousResponsesHistory(t.Context(), loader, "resp_limit", 1, nil) + require.Nil(t, history) + require.ErrorIs(t, err, transformer.ErrInvalidRequest) + require.ErrorContains(t, err, "previous_response_id history exceeds 33554432 bytes") + require.Equal(t, []previousResponseLoadCall{ + {responseID: "resp_limit", projectID: 1}, + {responseID: "resp_next", projectID: 1}, + }, loader.calls) +} + +func TestPersistentOutboundTransformer_PreviousResponseIDRouting(t *testing.T) { + newProcessor := func(out *mockTransformer, state *PersistenceState) *PersistentOutboundTransformer { + channel := &biz.Channel{ + Channel: &ent.Channel{ID: 1, Name: "test-channel"}, + Outbound: out, + } + state.ChannelModelsCandidates = []*ChannelModelsCandidate{{ + Channel: channel, + Models: []biz.ChannelModelEntry{{RequestModel: "gpt-5.5", ActualModel: "Kimi-K3"}}, + }} + return &PersistentOutboundTransformer{wrapped: out, state: state} + } + + t.Run("Responses outbound keeps previous_response_id without storage", func(t *testing.T) { + out := &mockTransformer{apiFormat: llm.APIFormatOpenAIResponse} + processor := newProcessor(out, &PersistenceState{}) + req := &llm.Request{ + Model: "gpt-5.5", + APIFormat: llm.APIFormatOpenAIResponse, + PreviousResponseID: lo.ToPtr("resp_1"), + Messages: []llm.Message{{Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("next")}}}, + } + + _, err := processor.TransformRequest(t.Context(), req) + require.NoError(t, err) + require.NotNil(t, out.capturedRequest.PreviousResponseID) + require.Equal(t, "resp_1", *out.capturedRequest.PreviousResponseID) + }) + + t.Run("Chat outbound prepends stored history and clears previous_response_id", func(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:responses_history_route?mode=memory&_fk=0") + ctx := ent.NewContext(authz.WithTestBypass(t.Context()), client) + seedPrimaryDatabaseStorage(t, ctx, client) + requestService := createTestRequestService(t, client) + _, err := client.Request.Create(). + SetProjectID(7). + SetModelID("gpt-5.5"). + SetFormat(llm.APIFormatOpenAIResponse.String()). + SetSource(request.SourceAPI). + SetStatus(request.StatusCompleted). + SetStream(false). + SetRequestBody(objects.JSONRawMessage(`{"model":"gpt-5.5","input":"first"}`)). + SetResponseBody(objects.JSONRawMessage(`{"id":"resp_1","model":"gpt-5.5","output":[{"type":"function_call","name":"lookup","arguments":"{}","call_id":"call_1"}]}`)). + SetExternalID("resp_1"). + Save(ctx) + require.NoError(t, err) + + out := &mockTransformer{apiFormat: llm.APIFormatOpenAIChatCompletion} + processor := newProcessor(out, &PersistenceState{ + RequestService: requestService, + Request: &ent.Request{ProjectID: 7}, + }) + req := &llm.Request{ + Model: "gpt-5.5", + APIFormat: llm.APIFormatOpenAIResponse, + PreviousResponseID: lo.ToPtr("resp_1"), + Messages: []llm.Message{{ + Role: "tool", + ToolCallID: lo.ToPtr("call_1"), + Content: llm.MessageContent{Content: lo.ToPtr("result")}, + }}, + } + + _, err = processor.TransformRequest(ctx, req) + require.NoError(t, err) + require.Nil(t, out.capturedRequest.PreviousResponseID) + require.Len(t, out.capturedRequest.Messages, 3) + require.Equal(t, "user", out.capturedRequest.Messages[0].Role) + require.Equal(t, "assistant", out.capturedRequest.Messages[1].Role) + require.Equal(t, "tool", out.capturedRequest.Messages[2].Role) + // Hydration uses a clone. Retries receive the original request and cannot + // accumulate the same history repeatedly. + require.Equal(t, "resp_1", lo.FromPtr(req.PreviousResponseID)) + require.Len(t, req.Messages, 1) + }) +} + +func TestRequestService_LoadCompletedResponseExchange_ScopesByProjectAndAPIKey(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:responses_history_scope?mode=memory&_fk=0") + ctx := ent.NewContext(authz.WithTestBypass(t.Context()), client) + seedPrimaryDatabaseStorage(t, ctx, client) + requestService := createTestRequestService(t, client) + + seed := func(projectID int, apiKeyID *int, marker string) { + create := client.Request.Create(). + SetProjectID(projectID). + SetModelID("gpt-5.5"). + SetFormat(llm.APIFormatOpenAIResponse.String()). + SetSource(request.SourceAPI). + SetStatus(request.StatusCompleted). + SetStream(false). + SetRequestBody(objects.JSONRawMessage(`{"model":"gpt-5.5","input":"` + marker + `"}`)). + SetResponseBody(objects.JSONRawMessage(`{"id":"shared_response_id","model":"gpt-5.5","output":[]}`)). + SetExternalID("shared_response_id") + if apiKeyID != nil { + create.SetAPIKeyID(*apiKeyID) + } + _, err := create.Save(ctx) + require.NoError(t, err) + } + + key10 := 10 + key20 := 20 + seed(1, &key10, "project-1-key-10") + seed(1, &key20, "project-1-key-20") + seed(2, &key10, "project-2-key-10") + seed(1, nil, "project-1-admin") + + exchange, err := requestService.LoadCompletedResponseExchange(ctx, "shared_response_id", 1, &key20, maxPreviousResponseHistoryBytes) + require.NoError(t, err) + require.JSONEq(t, `{"model":"gpt-5.5","input":"project-1-key-20"}`, string(exchange.RequestBody)) + + exchange, err = requestService.LoadCompletedResponseExchange(ctx, "shared_response_id", 1, nil, maxPreviousResponseHistoryBytes) + require.NoError(t, err) + require.JSONEq(t, `{"model":"gpt-5.5","input":"project-1-admin"}`, string(exchange.RequestBody)) + + missingKey := 99 + exchange, err = requestService.LoadCompletedResponseExchange(ctx, "shared_response_id", 1, &missingKey, maxPreviousResponseHistoryBytes) + require.NoError(t, err) + require.Nil(t, exchange) + + exchange, err = requestService.LoadCompletedResponseExchange(ctx, "shared_response_id", 1, &key10, maxPreviousResponseHistoryBytes) + require.NoError(t, err) + require.JSONEq(t, `{"model":"gpt-5.5","input":"project-1-key-10"}`, string(exchange.RequestBody)) +} + +func TestRequestService_LoadCompletedResponseExchange_EnforcesPersistedJSONByteBudget(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:responses_history_body_limit?mode=memory&_fk=0") + ctx := ent.NewContext(authz.WithTestBypass(t.Context()), client) + seedPrimaryDatabaseStorage(t, ctx, client) + requestService := createTestRequestService(t, client) + + requestBody := objects.JSONRawMessage(`{ + "z": "\u4f60", + "model": "gpt-5.5", + "input": "你好", + "slash": "\/", + "html": "<>&", + "a": "你" + }`) + responseBody := objects.JSONRawMessage(`{ + "output": [], + "model": "gpt-5.5", + "id": "resp_limited" + }`) + created, err := client.Request.Create(). + SetProjectID(1). + SetModelID("gpt-5.5"). + SetFormat(llm.APIFormatOpenAIResponse.String()). + SetSource(request.SourceAPI). + SetStatus(request.StatusCompleted). + SetStream(false). + SetRequestBody(requestBody). + SetResponseBody(responseBody). + SetExternalID("resp_limited"). + Save(ctx) + require.NoError(t, err) + + persisted, err := client.Request.Get(ctx, created.ID) + require.NoError(t, err) + require.NotEqual(t, requestBody, persisted.RequestBody) + require.NotEqual(t, responseBody, persisted.ResponseBody) + require.JSONEq(t, string(requestBody), string(persisted.RequestBody)) + require.JSONEq(t, string(responseBody), string(persisted.ResponseBody)) + + exactBudget := int64(len(persisted.RequestBody) + len(persisted.ResponseBody)) + exchange, err := requestService.LoadCompletedResponseExchange(ctx, "resp_limited", 1, nil, exactBudget) + require.NoError(t, err) + require.Equal(t, persisted.RequestBody, exchange.RequestBody) + require.Equal(t, persisted.ResponseBody, exchange.ResponseBody) + + exchange, err = requestService.LoadCompletedResponseExchange(ctx, "resp_limited", 1, nil, exactBudget-1) + require.Nil(t, exchange) + require.ErrorIs(t, err, biz.ErrStoredResponseExchangeTooLarge) +} diff --git a/llm/constants.go b/llm/constants.go index 295035035..45e5dcf07 100644 --- a/llm/constants.go +++ b/llm/constants.go @@ -82,4 +82,11 @@ const ( // ToolTypeResponsesCustomTool is the custom tool type for OpenAI Responses API. // Custom tools use freeform input (not JSON) and a grammar-based format definition. ToolTypeResponsesCustomTool = "responses_custom_tool" + + // ToolTypeResponsesToolSearch is the client-executed tool search type for OpenAI Responses API. + ToolTypeResponsesToolSearch = "responses_tool_search" + + // ToolTypeResponsesOpaqueTool preserves a Responses tool whose execution + // semantics cannot be represented by the common tool model. + ToolTypeResponsesOpaqueTool = "responses_opaque_tool" ) diff --git a/llm/tools.go b/llm/tools.go index 89834e786..67bb3352e 100644 --- a/llm/tools.go +++ b/llm/tools.go @@ -7,10 +7,12 @@ import ( "strings" ) -// Tool represents a function tool. +// Tool represents a callable or provider-native model tool. type Tool struct { // Type is the type of the tool. - // Any of "function", "image_generation", "web_search", or "google" (for Google-specific tools). + // Common values include "function", provider-native search/generation types, + // and the Responses-specific "responses_custom_tool", + // "responses_tool_search", and "responses_opaque_tool" variants. Type string `json:"type"` // Function is the function definition, will be used when Type is "function". @@ -27,17 +29,32 @@ type Tool struct { Google *GoogleTools `json:"google,omitempty"` // ResponseCustomTool is the custom tool definition for OpenAI Responses API. - // Will be used when Type is "custom". + // Will be used when Type is "responses_custom_tool". ResponseCustomTool *ResponseCustomTool `json:"response_custom_tool,omitempty"` - // CacheControl is used for provider-specific cache control (e.g., Anthropic). - // This field is not serialized in JSON. + // ResponseToolSearch is the client-executed tool search definition for OpenAI Responses API. + // Will be used when Type is "responses_tool_search". + ResponseToolSearch *ResponseToolSearch `json:"response_tool_search,omitempty"` + + // ResponseOpaqueTool identifies a Responses tool that is retained for + // same-protocol replay but cannot be translated without an executor/codec. + // Will be used when Type is "responses_opaque_tool". + ResponseOpaqueTool *ResponseOpaqueTool `json:"response_opaque_tool,omitempty"` + + // CacheControl is serialized in the unified LLM JSON; provider transformers + // decide whether to emit it upstream. CacheControl *CacheControl `json:"cache_control,omitempty"` + + // ResponsesOrigin marks tools whose original Responses declaration is replayed + // from a raw top-level or position-sensitive input fragment. + ResponsesOrigin string `json:"-"` } // Function represents a function definition. type Function struct { - Name string `json:"name"` + Name string `json:"name"` + // Namespace is populated when a Responses namespace function is flattened for other APIs. + Namespace string `json:"namespace,omitempty"` Description string `json:"description,omitempty"` Parameters json.RawMessage `json:"parameters,omitempty"` // ParametersJsonSchema is the newer Gemini format that supports full JSON Schema Draft 2020-12 @@ -65,15 +82,21 @@ type FunctionCall struct { type ToolCall struct { ID string `json:"id,omitempty"` - // The type of the tool. Currently, only `function` is supported. + // Type identifies a function call or a Responses-specific custom/tool-search call. + // Supported common-model values are "function", "responses_custom_tool", + // and "responses_tool_search". Type string `json:"type,omitempty"` Function FunctionCall `json:"function"` // ResponseCustomToolCall holds the custom tool call data for OpenAI Responses API. - // Will be used when Type is "custom". + // Will be used when Type is "responses_custom_tool". ResponseCustomToolCall *ResponseCustomToolCall `json:"response_custom_tool_call,omitempty"` + // ResponseToolSearchCall holds a client-executed Responses tool search call. + // Will be used when Type is "responses_tool_search". + ResponseToolSearchCall *ResponseToolSearchCall `json:"response_tool_search_call,omitempty"` + // Index is the index of the tool call in the list of tool calls. // Cannot use omitempty, as an index of 0 would be omitted, which can break consumers. Index int `json:"index"` @@ -275,3 +298,26 @@ type ResponseCustomToolCall struct { // Input is the freeform input for the custom tool call generated by the model. Input string `json:"input"` } + +// ResponseToolSearch represents a client-executed Responses tool search definition. +type ResponseToolSearch struct { + Execution string `json:"execution,omitempty"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +// ResponseToolSearchCall represents a client-executed Responses tool search call. +type ResponseToolSearchCall struct { + CallID string `json:"call_id"` + Execution string `json:"execution,omitempty"` + Arguments string `json:"arguments"` +} + +// ResponseOpaqueTool describes an unsupported Responses tool without +// inventing Chat execution semantics for it. +type ResponseOpaqueTool struct { + SourceType string `json:"source_type"` + Name string `json:"name,omitempty"` + Execution string `json:"execution,omitempty"` + Description string `json:"description,omitempty"` +} diff --git a/llm/transformer/openai/outbound.go b/llm/transformer/openai/outbound.go index 4adf54f08..d283b0143 100644 --- a/llm/transformer/openai/outbound.go +++ b/llm/transformer/openai/outbound.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" + "maps" "net/http" "strings" @@ -196,7 +198,10 @@ func (t *OutboundTransformer) TransformRequest(ctx context.Context, llmReq *llm. } // Convert to OpenAI Request format (this strips helper fields) - oaiReq := RequestFromLLM(llmReq, reasoningField) + oaiReq, toolAdapter, err := requestFromLLMWithResponsesToolAdapter(llmReq, reasoningField) + if err != nil { + return nil, fmt.Errorf("%w: failed to convert Responses tools to Chat Completions: %v", transformer.ErrInvalidRequest, err) + } // Apply per-channel reasoning_effort mapping for non-standard OpenAI-compatible providers. // Entries in the map replace the effort value; values not in the map pass through unchanged. // e.g. ollama channel with {"xhigh": "max"} converts Anthropic's internal "xhigh" back to "max". @@ -231,14 +236,32 @@ func (t *OutboundTransformer) TransformRequest(ctx context.Context, llmReq *llm. return nil, fmt.Errorf("failed to build platform URL: %w", err) } + transformerMetadata := maps.Clone(llmReq.TransformerMetadata) + if transformerMetadata == nil { + transformerMetadata = map[string]any{} + } + if mappings := toolAdapter.mappings(); len(mappings) > 0 { + transformerMetadata[responsesChatToolMappingsMetadataKey] = mappings + } + if catalog := toolAdapter.catalog(); len(catalog) > 0 { + transformerMetadata[responsesChatToolCatalogMetadataKey] = catalog + } + if len(toolAdapter.warnings) > 0 { + transformerMetadata[responsesChatToolWarningsMetadataKey] = append([]string(nil), toolAdapter.warnings...) + slog.WarnContext(ctx, "Responses request degraded during Chat Completions conversion", + slog.String("model", llmReq.Model), + slog.Any("warnings", toolAdapter.warnings)) + } + return &httpclient.Request{ - Method: http.MethodPost, - URL: url, - Headers: headers, - Body: body, - Auth: authConfig, - APIFormat: string(llm.APIFormatOpenAIChatCompletion), - Metadata: nil, + Method: http.MethodPost, + URL: url, + Headers: headers, + Body: body, + Auth: authConfig, + APIFormat: string(llm.APIFormatOpenAIChatCompletion), + Metadata: nil, + TransformerMetadata: transformerMetadata, }, nil } @@ -291,8 +314,11 @@ func (t *OutboundTransformer) TransformResponse( return nil, fmt.Errorf("failed to unmarshal chat completion response: %w", err) } - // Convert to unified llm.Response - return oaiResp.ToLLMResponse(), nil + // Convert to unified llm.Response and restore Responses-only calls that were + // represented as Chat function calls for this request. + llmResp := oaiResp.ToLLMResponse() + restoreResponsesChatToolCalls(llmResp, responsesChatToolMappings(httpResp.Request)) + return llmResp, nil } func (t *OutboundTransformer) TransformStream(ctx context.Context, req *httpclient.Request, stream streams.Stream[*httpclient.StreamEvent]) (streams.Stream[*llm.Response], error) { @@ -314,11 +340,34 @@ func (t *OutboundTransformer) TransformStream(ctx context.Context, req *httpclie // // Note: TransformStreamChunk only returns nil for events with explicit "choices":[] // in the raw JSON. Events without a choices key (nil slice) are passed through. + restorer := newResponsesChatToolStreamRestorer(responsesChatToolMappings(req), responsesChatToolCatalog(req)) return streams.NoNil(streams.MapErr(stream, func(event *httpclient.StreamEvent) (*llm.Response, error) { - return t.TransformStreamChunk(ctx, event) + response, err := t.TransformStreamChunk(ctx, event) + if err == nil { + restorer.restore(response) + } + return response, err })), nil } +// responsesChatToolMappings retrieves per-request mappings used to restore Responses calls. +func responsesChatToolMappings(req *httpclient.Request) map[string]responsesChatToolMapping { + if req == nil || req.TransformerMetadata == nil { + return nil + } + mappings, _ := req.TransformerMetadata[responsesChatToolMappingsMetadataKey].(map[string]responsesChatToolMapping) + return mappings +} + +// responsesChatToolCatalog retrieves callable names used to restore fragmented tool calls. +func responsesChatToolCatalog(req *httpclient.Request) []string { + if req == nil || req.TransformerMetadata == nil { + return nil + } + catalog, _ := req.TransformerMetadata[responsesChatToolCatalogMetadataKey].([]string) + return catalog +} + func (t *OutboundTransformer) TransformStreamChunk( ctx context.Context, event *httpclient.StreamEvent, diff --git a/llm/transformer/openai/outbound_convert.go b/llm/transformer/openai/outbound_convert.go index 9344dcbb9..90814333e 100644 --- a/llm/transformer/openai/outbound_convert.go +++ b/llm/transformer/openai/outbound_convert.go @@ -1,6 +1,8 @@ package openai import ( + "fmt" + "github.com/samber/lo" "github.com/looplj/axonhub/llm" @@ -11,7 +13,32 @@ func RequestFromLLM(r *llm.Request, reasoningField ReasoningField) *Request { if r == nil { return nil } + req := requestFromLLMBase(r) + req.Messages = lo.Map(r.Messages, func(m llm.Message, _ int) Message { + return MessageFromLLMWithConfig(m, reasoningField) + }) + req.Tools = lo.FilterMap(r.Tools, func(tool llm.Tool, _ int) (Tool, bool) { + return ToolFromLLM(tool), tool.Type == llm.ToolTypeFunction + }) + if r.ToolChoice != nil { + req.ToolChoice = &ToolChoice{ToolChoice: r.ToolChoice.ToolChoice} + if r.ToolChoice.NamedToolChoice != nil { + req.ToolChoice.NamedToolChoice = &NamedToolChoice{ + Type: r.ToolChoice.NamedToolChoice.Type, + Function: ToolFunction{ + Name: r.ToolChoice.NamedToolChoice.Function.Name, + }, + } + } + } + if len(req.Tools) == 0 { + req.ParallelToolCalls = nil + } + return req +} +// requestFromLLMBase converts fields shared by plain and Responses-adapted Chat requests. +func requestFromLLMBase(r *llm.Request) *Request { req := &Request{ Model: r.Model, FrequencyPenalty: r.FrequencyPenalty, @@ -36,62 +63,72 @@ func RequestFromLLM(r *llm.Request, reasoningField ReasoningField) *Request { ParallelToolCalls: r.ParallelToolCalls, Verbosity: r.Verbosity, } - - // Convert messages - req.Messages = lo.Map(r.Messages, func(m llm.Message, _ int) Message { - return MessageFromLLMWithConfig(m, reasoningField) - }) - - // Convert Stop if r.Stop != nil { - req.Stop = &Stop{ - Stop: r.Stop.Stop, - MultipleStop: r.Stop.MultipleStop, - } + req.Stop = &Stop{Stop: r.Stop.Stop, MultipleStop: r.Stop.MultipleStop} } - - // Convert StreamOptions if r.StreamOptions != nil { - req.StreamOptions = &StreamOptions{ - IncludeUsage: r.StreamOptions.IncludeUsage, - } + req.StreamOptions = &StreamOptions{IncludeUsage: r.StreamOptions.IncludeUsage} } - - // Convert Tools – only include function tools; other types - // (image_generation, responses_custom_tool, etc.) are not supported - // by the Chat Completions API and must be filtered out. - req.Tools = lo.FilterMap(r.Tools, func(t llm.Tool, _ int) (Tool, bool) { - return ToolFromLLM(t), t.Type == llm.ToolTypeFunction - }) - - // Convert ToolChoice - if r.ToolChoice != nil { - req.ToolChoice = &ToolChoice{ - ToolChoice: r.ToolChoice.ToolChoice, - } - if r.ToolChoice.NamedToolChoice != nil { - req.ToolChoice.NamedToolChoice = &NamedToolChoice{ - Type: r.ToolChoice.NamedToolChoice.Type, - Function: ToolFunction{ - Name: r.ToolChoice.NamedToolChoice.Function.Name, - }, - } + if r.ResponseFormat != nil { + req.ResponseFormat = &ResponseFormat{ + Type: r.ResponseFormat.Type, JSONSchema: r.ResponseFormat.JSONSchema, } } + return req +} - // Convert ResponseFormat - if r.ResponseFormat != nil { - req.ResponseFormat = &ResponseFormat{ - Type: r.ResponseFormat.Type, - JSONSchema: r.ResponseFormat.JSONSchema, +// requestFromLLMWithResponsesToolAdapter converts a request and preserves reversible tool mappings. +func requestFromLLMWithResponsesToolAdapter(r *llm.Request, reasoningField ReasoningField) (*Request, *responsesChatToolAdapter, error) { + if r == nil { + return nil, nil, nil + } + toolAdapter := newResponsesChatToolAdapter(r.Tools) + + req := requestFromLLMBase(r) + + // Build the callable catalog before converting history so specialized calls + // resolve through the same stable names as the current tool declarations. + req.Tools = toolAdapter.convertTools(r.Tools) + + // Convert messages. Responses can retain assistant-only metadata such as + // encrypted reasoning or compaction items that Chat Completions cannot + // represent. Once those fields are stripped, omit the empty assistant + // message instead of sending an invalid history entry to the provider. + droppedEmptyAssistants := 0 + req.Messages = lo.FilterMap(r.Messages, func(m llm.Message, _ int) (Message, bool) { + converted := toolAdapter.convertMessage(m, reasoningField) + if !hasChatAssistantPayload(converted) { + droppedEmptyAssistants++ + return Message{}, false } + return converted, true + }) + if droppedEmptyAssistants > 0 { + toolAdapter.addWarning( + "empty_assistant_message: dropped %d history message(s) with no Chat-compatible payload", + droppedEmptyAssistants, + ) } + // Convert ToolChoice + req.ToolChoice = toolAdapter.convertToolChoice(r.ToolChoice) + if len(req.Tools) == 0 { req.ParallelToolCalls = nil + if req.ToolChoice != nil && req.ToolChoice.ToolChoice != nil { + switch *req.ToolChoice.ToolChoice { + case "auto", "none": + req.ToolChoice = nil + case "required": + toolAdapter.setError(fmt.Errorf("unsupported_tool_choice: required tool choice has no callable tools after Responses-to-Chat conversion")) + } + } } - return req + if toolAdapter.err != nil { + return nil, toolAdapter, toolAdapter.err + } + return req, toolAdapter, nil } // applyReasoningEffortMapping replaces reasoning_effort according to a per-channel mapping. diff --git a/llm/transformer/openai/outbound_convert_test.go b/llm/transformer/openai/outbound_convert_test.go index fad9c0a0e..8b6c79843 100644 --- a/llm/transformer/openai/outbound_convert_test.go +++ b/llm/transformer/openai/outbound_convert_test.go @@ -1,6 +1,7 @@ package openai import ( + "encoding/json" "testing" "github.com/samber/lo" @@ -75,7 +76,7 @@ func TestRequestFromLLM(t *testing.T) { } } -func TestRequestFromLLM_FiltersResponsesCustomTools(t *testing.T) { +func TestRequestFromLLM_FiltersResponsesOnlyToolsWithoutLifecycleMetadata(t *testing.T) { req := RequestFromLLM(&llm.Request{ Model: "gpt-4o", Messages: []llm.Message{{Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("hi")}}}, @@ -93,12 +94,419 @@ func TestRequestFromLLM_FiltersResponsesCustomTools(t *testing.T) { Parameters: []byte(`{"type":"object"}`), }, }, + { + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearch: &llm.ResponseToolSearch{ + Execution: "client", Description: "Find deferred tools", + Parameters: []byte(`{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}`), + }, + }, }, }, ReasoningFieldNone) require.NotNil(t, req) require.Len(t, req.Tools, 1) - require.Equal(t, llm.ToolTypeFunction, req.Tools[0].Type) + for _, tool := range req.Tools { + require.Equal(t, llm.ToolTypeFunction, tool.Type) + } + require.Equal(t, "get_weather", req.Tools[0].Function.Name) +} + +func TestResponsesChatToolAdapter_ConvertsHistoryAndRestoresCalls(t *testing.T) { + request := &llm.Request{ + Tools: []llm.Tool{ + {Type: llm.ToolTypeResponsesCustomTool, ResponseCustomTool: &llm.ResponseCustomTool{Name: "apply_patch"}}, + {Type: llm.ToolTypeResponsesToolSearch, ResponseToolSearch: &llm.ResponseToolSearch{Execution: "client"}}, + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "collaboration__spawn_agent", Namespace: "collaboration"}}, + }, + Messages: []llm.Message{{ + Role: "assistant", + ToolCalls: []llm.ToolCall{ + {ID: "call_custom", ResponseCustomToolCall: &llm.ResponseCustomToolCall{CallID: "call_custom", Name: "apply_patch", Input: "*** Begin Patch"}}, + {ID: "call_search", ResponseToolSearchCall: &llm.ResponseToolSearchCall{CallID: "call_search", Execution: "client", Arguments: `{"query":"agents"}`}}, + {ID: "call_ns", Function: llm.FunctionCall{Name: "spawn_agent", Namespace: "collaboration", Arguments: `{}`}}, + }, + }}, + } + + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Len(t, chatRequest.Messages[0].ToolCalls, 3) + require.Equal(t, llm.ToolTypeFunction, chatRequest.Messages[0].ToolCalls[0].Type) + require.JSONEq(t, `{"input":"*** Begin Patch"}`, chatRequest.Messages[0].ToolCalls[0].Function.Arguments) + require.Equal(t, "tool_search", chatRequest.Messages[0].ToolCalls[1].Function.Name) + require.Equal(t, "collaboration__spawn_agent", chatRequest.Messages[0].ToolCalls[2].Function.Name) + + response := &llm.Response{Choices: []llm.Choice{{Message: &llm.Message{ToolCalls: []llm.ToolCall{ + {ID: "call_custom", Function: llm.FunctionCall{Name: "apply_patch", Arguments: `{"input":"*** Begin Patch"}`}}, + {ID: "call_search", Function: llm.FunctionCall{Name: "tool_search", Arguments: `{"query":"agents"}`}}, + {ID: "call_ns", Function: llm.FunctionCall{Name: "collaboration__spawn_agent", Arguments: `{}`}}, + }}}}} + restoreResponsesChatToolCalls(response, adapter.mappings()) + calls := response.Choices[0].Message.ToolCalls + require.Equal(t, "*** Begin Patch", calls[0].ResponseCustomToolCall.Input) + require.Equal(t, `{"query":"agents"}`, calls[1].ResponseToolSearchCall.Arguments) + require.Equal(t, "collaboration", calls[2].Function.Namespace) + require.Equal(t, "spawn_agent", calls[2].Function.Name) +} + +func TestResponsesChatToolAdapter_DropsEmptyAssistantHistoryMessages(t *testing.T) { + request := &llm.Request{Messages: []llm.Message{ + {Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("before")}}, + { + Role: "assistant", + ReasoningItems: []llm.ReasoningItem{{ID: "rs_empty", Signature: "opaque"}}, + ReasoningSignature: lo.ToPtr("opaque"), + }, + {Role: "assistant", Content: llm.MessageContent{Content: lo.ToPtr(" \n ")}}, + { + Role: "assistant", + Content: llm.MessageContent{MultipleContent: []llm.MessageContentPart{{ + Type: "compaction", Compact: &llm.CompactContent{ID: "cmp_1", EncryptedContent: "opaque"}, + }}}, + }, + {Role: "assistant", ReasoningContent: lo.ToPtr("kept reasoning")}, + {Role: "assistant", ToolCalls: []llm.ToolCall{{ID: "call_1", Type: "function", Function: llm.FunctionCall{Name: "lookup", Arguments: `{}`}}}}, + {Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("after")}}, + }} + + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldContent) + require.NoError(t, err) + require.Len(t, chatRequest.Messages, 4) + require.Equal(t, []string{"user", "assistant", "assistant", "user"}, []string{ + chatRequest.Messages[0].Role, + chatRequest.Messages[1].Role, + chatRequest.Messages[2].Role, + chatRequest.Messages[3].Role, + }) + require.Equal(t, "kept reasoning", lo.FromPtr(chatRequest.Messages[1].ReasoningContent)) + require.Len(t, chatRequest.Messages[2].ToolCalls, 1) + require.Contains(t, adapter.warnings, "empty_assistant_message: dropped 3 history message(s) with no Chat-compatible payload") +} + +func TestResponsesChatToolStreamRestorer_UsesToolCallIndexForLaterChunks(t *testing.T) { + mappings := map[string]responsesChatToolMapping{ + "apply_patch": {Kind: responsesChatToolCustom, ChatName: "apply_patch", Name: "apply_patch"}, + } + restorer := newResponsesChatToolStreamRestorer(mappings) + first := &llm.Response{Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{{ + ID: "call_1", Index: 0, Function: llm.FunctionCall{Name: "apply_patch", Arguments: `{"input":"*** Begin`}, + }}}}}} + second := &llm.Response{Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{{ + Index: 0, Function: llm.FunctionCall{Arguments: ` Patch"}`}, + }}}}}} + + restorer.restore(first) + restorer.restore(second) + require.NotNil(t, first.Choices[0].Delta.ToolCalls[0].ResponseCustomToolCall) + require.NotNil(t, second.Choices[0].Delta.ToolCalls[0].ResponseCustomToolCall) + require.Equal(t, ` Patch"}`, second.Choices[0].Delta.ToolCalls[0].ResponseCustomToolCall.Input) +} + +func TestResponsesChatToolStreamRestorer_DoesNotRestoreHistoryOnlyMapping(t *testing.T) { + mappings := map[string]responsesChatToolMapping{ + "apply_patch": { + Kind: responsesChatToolCustom, ChatName: "apply_patch", Name: "apply_patch", HistoryOnly: true, + }, + } + restorer := newResponsesChatToolStreamRestorer(mappings) + first := &llm.Response{Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{{ + ID: "call_1", Index: 0, Type: llm.ToolTypeFunction, + Function: llm.FunctionCall{Name: "apply_patch", Arguments: `{"input":"*** Begin`}, + }}}}}} + second := &llm.Response{Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{{ + Index: 0, Type: llm.ToolTypeFunction, Function: llm.FunctionCall{Arguments: ` Patch"}`}, + }}}}}} + + restorer.restore(first) + restorer.restore(second) + require.Nil(t, first.Choices[0].Delta.ToolCalls[0].ResponseCustomToolCall) + require.Nil(t, second.Choices[0].Delta.ToolCalls[0].ResponseCustomToolCall) + require.Empty(t, second.Choices[0].Delta.ToolCalls[0].Function.Name) +} + +func TestResponsesChatToolAdapter_AvoidsFunctionNameCollisions(t *testing.T) { + request := &llm.Request{Tools: []llm.Tool{ + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "apply_patch"}}, + {Type: llm.ToolTypeResponsesCustomTool, ResponseCustomTool: &llm.ResponseCustomTool{Name: "apply_patch"}}, + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "collaboration__spawn_agent"}}, + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "collaboration__spawn_agent", Namespace: "collaboration"}}, + }} + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Len(t, chatRequest.Tools, 4) + require.Equal(t, "apply_patch", chatRequest.Tools[0].Function.Name) + require.Equal(t, "axonhub_custom_tool_1", chatRequest.Tools[1].Function.Name) + require.Equal(t, "collaboration__spawn_agent", chatRequest.Tools[2].Function.Name) + require.Equal(t, "axonhub_namespace_tool_1", chatRequest.Tools[3].Function.Name) + + response := &llm.Response{Choices: []llm.Choice{{Message: &llm.Message{ToolCalls: []llm.ToolCall{ + {ID: "custom", Function: llm.FunctionCall{Name: "axonhub_custom_tool_1", Arguments: `{"input":"patch"}`}}, + {ID: "namespace", Function: llm.FunctionCall{Name: "axonhub_namespace_tool_1", Arguments: `{}`}}, + }}}}} + restoreResponsesChatToolCalls(response, adapter.mappings()) + require.Equal(t, "patch", response.Choices[0].Message.ToolCalls[0].ResponseCustomToolCall.Input) + require.Equal(t, "collaboration", response.Choices[0].Message.ToolCalls[1].Function.Namespace) + require.Equal(t, "spawn_agent", response.Choices[0].Message.ToolCalls[1].Function.Name) +} + +func TestResponsesChatToolAdapter_DeduplicatesEquivalentFunctions(t *testing.T) { + request := &llm.Request{Tools: []llm.Tool{ + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "lookup", Description: "Lookup", Parameters: []byte(`{"type":"object","properties":{"query":{"type":"string"}}}`)}}, + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "lookup", Description: "Lookup", Parameters: []byte(`{"properties":{"query":{"type":"string"}},"type":"object"}`)}, ResponsesOrigin: "additional_tools"}, + }} + chatRequest, _, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Len(t, chatRequest.Tools, 1) + require.Equal(t, "lookup", chatRequest.Tools[0].Function.Name) +} + +func TestResponsesChatToolAdapter_KeepsFirstConflictingFunctionSchema(t *testing.T) { + request := &llm.Request{Tools: []llm.Tool{ + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "lookup", Parameters: []byte(`{"type":"object","properties":{"query":{"type":"string"}}}`)}}, + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "lookup", Parameters: []byte(`{"type":"object","properties":{"id":{"type":"integer"}}}`)}, ResponsesOrigin: "additional_tools"}, + }} + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Len(t, chatRequest.Tools, 1) + require.JSONEq(t, `{"type":"object","properties":{"query":{"type":"string"}}}`, string(chatRequest.Tools[0].Function.Parameters)) + require.Contains(t, adapter.warnings, `tool_name_conflict: kept first definition of function "lookup"`) +} + +func TestResponsesChatToolAdapter_DropsInvalidFunctionSchemaWithWarning(t *testing.T) { + request := &llm.Request{Tools: []llm.Tool{ + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "valid", Parameters: []byte(`{"type":"object"}`)}}, + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "invalid", Parameters: []byte(`{"type":`)}}, + }} + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Len(t, chatRequest.Tools, 1) + require.Equal(t, "valid", chatRequest.Tools[0].Function.Name) + require.Contains(t, adapter.warnings[0], `invalid function tool "invalid" was dropped`) +} + +func TestResponsesChatToolAdapter_NormalizesFunctionParameterRoots(t *testing.T) { + request := &llm.Request{Tools: []llm.Tool{ + {Type: llm.ToolTypeFunction, Function: llm.Function{ + Name: "plain", Parameters: []byte(`{"properties":{"query":{"type":"string"}}}`), + }}, + {Type: llm.ToolTypeFunction, Function: llm.Function{ + Name: "agents__spawn", Namespace: "agents", + }}, + {Type: llm.ToolTypeResponsesToolSearch, ResponseToolSearch: &llm.ResponseToolSearch{ + Execution: "client", Parameters: []byte(`null`), + }}, + {Type: llm.ToolTypeFunction, Function: llm.Function{ + Name: "invalid_array", Parameters: []byte(`{"type":"array","items":{"type":"string"}}`), + }}, + }} + + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Len(t, chatRequest.Tools, 3) + for _, tool := range chatRequest.Tools { + var schema map[string]any + require.NoError(t, json.Unmarshal(tool.Function.Parameters, &schema)) + require.Equal(t, "object", schema["type"]) + } + require.JSONEq(t, `{"type":"object","properties":{"query":{"type":"string"}}}`, string(chatRequest.Tools[0].Function.Parameters)) + require.JSONEq(t, `{"type":"object","properties":{}}`, string(chatRequest.Tools[1].Function.Parameters)) + require.JSONEq(t, `{"type":"object","properties":{}}`, string(chatRequest.Tools[2].Function.Parameters)) + require.Contains(t, adapter.warnings, `invalid function tool "invalid_array" was dropped: parameters schema type is required and must be "object"`) +} + +func TestResponsesChatToolAdapter_RejectsNamedInvalidToolSearchSchema(t *testing.T) { + request := &llm.Request{ + Tools: []llm.Tool{{ + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearch: &llm.ResponseToolSearch{ + Execution: "client", Parameters: []byte(`{"type":"array"}`), + }, + }}, + ToolChoice: &llm.ToolChoice{NamedToolChoice: &llm.NamedToolChoice{ + Type: llm.ToolTypeResponsesToolSearch, Function: llm.ToolFunction{Name: "tool_search"}, + }}, + } + + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.Nil(t, chatRequest) + require.ErrorContains(t, err, `named tool "tool_search" is unavailable`) + require.Contains(t, adapter.warnings, `invalid tool_search definition was dropped: parameters schema type is required and must be "object"`) +} + +func TestResponsesChatToolAdapter_RejectsUnsupportedNamedToolChoice(t *testing.T) { + request := &llm.Request{ + Tools: []llm.Tool{{ + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearch: &llm.ResponseToolSearch{Execution: "server"}, + }}, + ToolChoice: &llm.ToolChoice{NamedToolChoice: &llm.NamedToolChoice{ + Type: "tool_search", Function: llm.ToolFunction{Name: "tool_search"}, + }}, + } + _, _, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.ErrorContains(t, err, "unsupported_tool_choice") + +} + +func TestResponsesChatToolAdapter_DoesNotRedirectDroppedNamedToolToPlainFunction(t *testing.T) { + request := &llm.Request{ + Tools: []llm.Tool{ + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "lookup", Parameters: []byte(`{"type":"object"}`)}}, + { + Type: llm.ToolTypeResponsesOpaqueTool, + ResponseOpaqueTool: &llm.ResponseOpaqueTool{ + SourceType: "future_server_tool", Name: "lookup", Execution: "server", + }, + }, + }, + ToolChoice: &llm.ToolChoice{NamedToolChoice: &llm.NamedToolChoice{ + Type: "future_server_tool", Function: llm.ToolFunction{Name: "lookup"}, + }}, + } + _, _, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.ErrorContains(t, err, "unsupported_tool_choice") +} + +func TestResponsesChatToolAdapter_RejectsRequiredChoiceWithoutCallableTools(t *testing.T) { + required := "required" + request := &llm.Request{ + Tools: []llm.Tool{{ + Type: llm.ToolTypeResponsesOpaqueTool, + ResponseOpaqueTool: &llm.ResponseOpaqueTool{ + SourceType: "future_server_tool", Name: "hosted", Execution: "server", + }, + }}, + ToolChoice: &llm.ToolChoice{ToolChoice: &required}, + } + _, _, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.ErrorContains(t, err, "required tool choice has no callable tools") + + request.Tools = append(request.Tools, llm.Tool{ + Type: llm.ToolTypeFunction, Function: llm.Function{Name: "lookup", Parameters: []byte(`{"type":"object"}`)}, + }) + chatRequest, _, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Equal(t, "required", lo.FromPtr(chatRequest.ToolChoice.ToolChoice)) +} + +func TestResponsesChatToolAdapter_MapsNamedCustomToolChoiceAfterCollision(t *testing.T) { + request := &llm.Request{ + Tools: []llm.Tool{ + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "apply_patch", Parameters: []byte(`{"type":"object"}`)}}, + {Type: llm.ToolTypeResponsesCustomTool, ResponseCustomTool: &llm.ResponseCustomTool{Name: "apply_patch"}}, + }, + ToolChoice: &llm.ToolChoice{NamedToolChoice: &llm.NamedToolChoice{ + Type: "custom", Function: llm.ToolFunction{Name: "apply_patch"}, + }}, + } + chatRequest, _, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.NotNil(t, chatRequest.ToolChoice) + require.Equal(t, "axonhub_custom_tool_1", chatRequest.ToolChoice.NamedToolChoice.Function.Name) +} + +func TestResponsesChatToolAdapter_MapsFunctionChoiceToNamespaceTool(t *testing.T) { + request := &llm.Request{ + Tools: []llm.Tool{{ + Type: llm.ToolTypeFunction, + Function: llm.Function{ + Name: "collaboration__spawn_agent", Namespace: "collaboration", + Parameters: []byte(`{"type":"object"}`), + }, + }}, + ToolChoice: &llm.ToolChoice{NamedToolChoice: &llm.NamedToolChoice{ + Type: llm.ToolTypeFunction, Function: llm.ToolFunction{Name: "spawn_agent"}, + }}, + } + + chatRequest, _, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.NotNil(t, chatRequest.ToolChoice) + require.Equal(t, "collaboration__spawn_agent", chatRequest.ToolChoice.NamedToolChoice.Function.Name) +} + +func TestResponsesChatToolAdapter_RejectsAmbiguousPlainAndNamespaceFunctionChoice(t *testing.T) { + request := &llm.Request{ + Tools: []llm.Tool{ + {Type: llm.ToolTypeFunction, Function: llm.Function{Name: "spawn_agent", Parameters: []byte(`{"type":"object"}`)}}, + { + Type: llm.ToolTypeFunction, + Function: llm.Function{ + Name: "collaboration__spawn_agent", Namespace: "collaboration", + Parameters: []byte(`{"type":"object"}`), + }, + }, + }, + ToolChoice: &llm.ToolChoice{NamedToolChoice: &llm.NamedToolChoice{ + Type: llm.ToolTypeFunction, Function: llm.ToolFunction{Name: "spawn_agent"}, + }}, + } + + _, _, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.ErrorContains(t, err, "ambiguous between plain and namespace tools") +} + +func TestResponsesChatToolAdapter_SilentlyFiltersEstablishedNonChatTools(t *testing.T) { + request := &llm.Request{Tools: []llm.Tool{ + {Type: llm.ToolTypeImageGeneration}, + {Type: llm.ToolTypeWebSearch}, + {Type: llm.ToolTypeGoogleSearch}, + {Type: llm.ToolTypeGoogleCodeExecution}, + {Type: llm.ToolTypeGoogleUrlContext}, + }} + + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Empty(t, chatRequest.Tools) + require.Empty(t, adapter.warnings) +} + +func TestResponsesChatToolAdapter_ValidatesSpecialCallIDs(t *testing.T) { + base := []llm.Tool{{ + Type: llm.ToolTypeResponsesCustomTool, + ResponseCustomTool: &llm.ResponseCustomTool{Name: "apply_patch"}, + }} + + request := &llm.Request{Tools: base, Messages: []llm.Message{{ + Role: "assistant", ToolCalls: []llm.ToolCall{{ + ResponseCustomToolCall: &llm.ResponseCustomToolCall{CallID: "call_inner", Name: "apply_patch", Input: "patch"}, + }}, + }}} + chatRequest, _, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Equal(t, "call_inner", chatRequest.Messages[0].ToolCalls[0].ID) + + request.Messages[0].ToolCalls[0].ID = "call_outer" + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Equal(t, "call_inner", chatRequest.Messages[0].ToolCalls[0].ID) + require.Contains(t, adapter.warnings, `tool_call_id_conflict: used specialized call ID "call_inner" instead of outer call ID "call_outer"`) +} + +func TestResponsesChatToolAdapter_PreservesUndeclaredSpecialHistory(t *testing.T) { + request := &llm.Request{Messages: []llm.Message{{ + Role: "assistant", + ToolCalls: []llm.ToolCall{ + {ResponseCustomToolCall: &llm.ResponseCustomToolCall{CallID: "call_custom", Name: "apply_patch", Input: "patch"}}, + {ResponseToolSearchCall: &llm.ResponseToolSearchCall{CallID: "call_search", Execution: "client", Arguments: `{}`}}, + }, + }}} + chatRequest, adapter, err := requestFromLLMWithResponsesToolAdapter(request, ReasoningFieldNone) + require.NoError(t, err) + require.Empty(t, chatRequest.Tools) + require.Len(t, chatRequest.Messages[0].ToolCalls, 2) + require.Equal(t, "apply_patch", chatRequest.Messages[0].ToolCalls[0].Function.Name) + require.Equal(t, "tool_search", chatRequest.Messages[0].ToolCalls[1].Function.Name) + require.Len(t, adapter.mappings(), 2) + + response := &llm.Response{Choices: []llm.Choice{{Message: &llm.Message{ToolCalls: []llm.ToolCall{{ + ID: "call_new", Type: llm.ToolTypeFunction, + Function: llm.FunctionCall{Name: "apply_patch", Arguments: `{"input":"new patch"}`}, + }}}}}} + restoreResponsesChatToolCalls(response, adapter.mappings()) + require.Nil(t, response.Choices[0].Message.ToolCalls[0].ResponseCustomToolCall) + require.Equal(t, llm.ToolTypeFunction, response.Choices[0].Message.ToolCalls[0].Type) } func TestMessageContentPartAudioRoundTrip(t *testing.T) { diff --git a/llm/transformer/openai/outbound_test.go b/llm/transformer/openai/outbound_test.go index 467a49bc0..2be682117 100644 --- a/llm/transformer/openai/outbound_test.go +++ b/llm/transformer/openai/outbound_test.go @@ -14,6 +14,7 @@ import ( "github.com/looplj/axonhub/llm" "github.com/looplj/axonhub/llm/auth" "github.com/looplj/axonhub/llm/httpclient" + "github.com/looplj/axonhub/llm/transformer" ) func TestOutboundTransformer_TransformRequest(t *testing.T) { @@ -190,6 +191,34 @@ func TestOutboundTransformer_TransformRequest(t *testing.T) { } } +func TestOutboundTransformer_TransformRequest_ReportsUnsupportedResponsesToolChoiceAsInvalidRequest(t *testing.T) { + out, err := NewOutboundTransformer("https://api.openai.com/v1", "test-key") + assert.NoError(t, err) + + _, err = out.TransformRequest(t.Context(), &llm.Request{ + Model: "gpt-4o", + Messages: []llm.Message{{ + Role: "user", + Content: llm.MessageContent{Content: lo.ToPtr("use hosted search")}, + }}, + Tools: []llm.Tool{{ + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearch: &llm.ResponseToolSearch{ + Execution: "server", + }, + }}, + ToolChoice: &llm.ToolChoice{NamedToolChoice: &llm.NamedToolChoice{ + Type: "tool_search", + Function: llm.ToolFunction{ + Name: "tool_search", + }, + }}, + }) + + assert.ErrorIs(t, err, transformer.ErrInvalidRequest) + assert.ErrorContains(t, err, "unsupported_tool_choice") +} + func TestOutboundTransformer_TransformRequest_StripsUnsupportedToolCallExtraContentForOpenAI(t *testing.T) { tests := []struct { name string diff --git a/llm/transformer/openai/responses/inbound.go b/llm/transformer/openai/responses/inbound.go index d49bedd05..e5483c7ce 100644 --- a/llm/transformer/openai/responses/inbound.go +++ b/llm/transformer/openai/responses/inbound.go @@ -270,6 +270,24 @@ func convertToLLMRequest(req *Request, rawBody ...[]byte) (*llm.Request, error) chatReq.Tools = tools } + // additional_tools and client tool_search_output are position-sensitive in + // Responses. Promote their definitions for non-Responses downstreams while + // retaining their origin so Responses replay does not duplicate them at top level. + for _, item := range req.Input.Items { + if item.Type != "additional_tools" && item.Type != "tool_search_output" { + continue + } + + tools, err := convertToolsToLLM(item.Tools) + if err != nil { + return nil, err + } + for i := range tools { + tools[i].ResponsesOrigin = item.Type + } + chatReq.Tools = append(chatReq.Tools, tools...) + } + // Convert text format to response format if req.Text != nil && req.Text.Format != nil && req.Text.Format.Type != "" { chatReq.ResponseFormat = &llm.ResponseFormat{ @@ -325,7 +343,8 @@ func convertToolChoiceToLLM(src *ToolChoice) *llm.ToolChoice { } // convertInputToMessages converts Responses API input to llm.Message slice. -// It handles merging consecutive tool calls that belong to the same assistant turn. +// It merges reasoning with its following output and consecutive tool calls +// that belong to the same assistant turn. func convertInputToMessages(input *Input) ([]llm.Message, error) { if input == nil { return nil, nil @@ -366,27 +385,24 @@ func convertInputToMessages(input *Input) ([]llm.Message, error) { continue } - if item.Type == "function_call" || item.Type == "custom_tool_call" { + // Responses represents each tool call in an assistant turn as a separate output item, + // while Chat Completions requires one assistant message containing all + // calls before their tool result messages. + if isToolCallItemType(item.Type) { msg := llm.Message{Role: "assistant"} - - for i < len(input.Items) { - callItem := &input.Items[i] - if callItem.Type != "function_call" && callItem.Type != "custom_tool_call" { - break - } - - callMsg, err := convertItemToMessage(callItem) + for i < len(input.Items) && isToolCallItemType(input.Items[i].Type) { + callMessage, err := convertItemToMessage(&input.Items[i]) if err != nil { return nil, err } - if callMsg != nil { - msg.ToolCalls = append(msg.ToolCalls, callMsg.ToolCalls...) + if callMessage != nil { + msg.ToolCalls = append(msg.ToolCalls, callMessage.ToolCalls...) } i++ } - - messages = append(messages, msg) - + if len(msg.ToolCalls) > 0 { + messages = append(messages, msg) + } continue } @@ -406,6 +422,15 @@ func convertInputToMessages(input *Input) ([]llm.Message, error) { return messages, nil } +func isToolCallItemType(itemType string) bool { + switch itemType { + case "function_call", "custom_tool_call", "tool_search_call": + return true + default: + return false + } +} + // convertReasoningWithFollowing converts a reasoning item and merges it with subsequent // function_call items or text content into a single assistant message. // Returns the merged message and the number of items consumed. @@ -484,6 +509,18 @@ func convertReasoningWithFollowing(items []Item, startIdx int) (*llm.Message, in }) consumed++ + case "tool_search_call": + msg.ToolCalls = append(msg.ToolCalls, llm.ToolCall{ + ID: nextItem.CallID, + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearchCall: &llm.ResponseToolSearchCall{ + CallID: nextItem.CallID, + Execution: nextItem.Execution, + Arguments: nextItem.Arguments, + }, + }) + consumed++ + case "message", "input_text", "": // If we encounter a text message with assistant role, merge its content if nextItem.Role == "assistant" { @@ -594,6 +631,20 @@ func convertItemToMessage(item *Item) (*llm.Message, error) { }, }, nil + case "tool_search_call": + return &llm.Message{ + Role: "assistant", + ToolCalls: []llm.ToolCall{{ + ID: item.CallID, + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearchCall: &llm.ResponseToolSearchCall{ + CallID: item.CallID, + Execution: item.Execution, + Arguments: item.Arguments, + }, + }}, + }, nil + case "function_call_output": if item.Output == nil { return nil, fmt.Errorf("%w: %s", transformer.ErrInvalidRequest, "function_call_output item must have non-nil Output") @@ -626,6 +677,21 @@ func convertItemToMessage(item *Item) (*llm.Message, error) { return msg, nil + case "tool_search_output": + tools := item.Tools + if tools == nil { + tools = []Tool{} + } + content, err := json.Marshal(tools) + if err != nil { + return nil, fmt.Errorf("failed to marshal tool_search_output tools: %w", err) + } + return &llm.Message{ + Role: "tool", + ToolCallID: lo.ToPtr(item.CallID), + Content: llm.MessageContent{Content: lo.ToPtr(string(content))}, + }, nil + case "reasoning": // Reasoning is handled by convertReasoningWithFollowing in convertInputToMessages // This case should not be reached in normal flow, but return nil to skip if it does @@ -823,9 +889,24 @@ func convertToolsToLLM(tools []Tool) ([]llm.Tool, error) { ResponseCustomTool: customTool, }) + case "tool_search": + params, err := json.Marshal(tool.Parameters) + if err != nil { + return nil, fmt.Errorf("failed to marshal tool search parameters: %w", err) + } + result = append(result, llm.Tool{ + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearch: &llm.ResponseToolSearch{ + Execution: tool.Execution, + Description: tool.Description, + Parameters: params, + }, + }) + case "namespace": for _, subTool := range tool.Tools { - if subTool.Type != "function" { + if subTool.Type != "function" && !isGenericClientFunctionLike(subTool) { + result = append(result, opaqueResponsesTool(subTool, "raw_tool")) continue } @@ -834,26 +915,67 @@ func convertToolsToLLM(tools []Tool) ([]llm.Tool, error) { return nil, fmt.Errorf("failed to marshal namespace tool parameters: %w", err) } - result = append(result, llm.Tool{ + converted := llm.Tool{ Type: "function", Function: llm.Function{ Name: namespaceFunctionName(tool.Name, subTool.Name), + Namespace: tool.Name, Description: subTool.Description, Parameters: params, Strict: subTool.Strict, }, - }) + } + if subTool.Type != "function" { + converted.ResponsesOrigin = "raw_tool" + } + result = append(result, converted) } default: - // Skip unsupported tool types - continue + if isGenericClientFunctionLike(tool) { + params, err := json.Marshal(tool.Parameters) + if err != nil { + return nil, fmt.Errorf("failed to marshal %s tool parameters: %w", tool.Type, err) + } + result = append(result, llm.Tool{ + Type: "function", + Function: llm.Function{ + Name: tool.Name, Description: tool.Description, + Parameters: params, Strict: tool.Strict, + }, + ResponsesOrigin: "raw_tool", + }) + continue + } + result = append(result, opaqueResponsesTool(tool, "raw_tool")) } } return result, nil } +// isGenericClientFunctionLike reports whether an unknown client tool can use function semantics. +func isGenericClientFunctionLike(tool Tool) bool { + return isFunctionLike(tool) && tool.Execution == "client" +} + +// isFunctionLike reports whether a Responses declaration has a function schema. +func isFunctionLike(tool Tool) bool { + return tool.Name != "" && tool.Parameters != nil +} + +// opaqueResponsesTool preserves an unsupported declaration for same-protocol replay. +func opaqueResponsesTool(tool Tool, origin string) llm.Tool { + return llm.Tool{ + Type: llm.ToolTypeResponsesOpaqueTool, + ResponseOpaqueTool: &llm.ResponseOpaqueTool{ + SourceType: tool.Type, Name: tool.Name, Execution: tool.Execution, + Description: tool.Description, + }, + ResponsesOrigin: origin, + } +} + func namespaceFunctionName(namespaceName, functionName string) string { return namespaceName + "__" + functionName } @@ -985,7 +1107,15 @@ func convertToResponsesAPIResponse(chatResp *llm.Response) *Response { // Handle tool calls (function calls and custom tool calls) if len(message.ToolCalls) > 0 { for _, toolCall := range message.ToolCalls { - if toolCall.ResponseCustomToolCall != nil { + if toolCall.ResponseToolSearchCall != nil { + resp.Output = append(resp.Output, Item{ + ID: toolCall.ID, Type: "tool_search_call", + CallID: toolCall.ResponseToolSearchCall.CallID, + Execution: toolCall.ResponseToolSearchCall.Execution, + Arguments: toolCall.ResponseToolSearchCall.Arguments, + Status: lo.ToPtr("completed"), + }) + } else if toolCall.ResponseCustomToolCall != nil { resp.Output = append(resp.Output, Item{ ID: toolCall.ID, Type: "custom_tool_call", @@ -1079,12 +1209,19 @@ func convertToResponsesAPIResponse(chatResp *llm.Response) *Response { switch *choice.FinishReason { case "stop": resp.Status = lo.ToPtr("completed") + resp.IncompleteDetails = nil case "length": resp.Status = lo.ToPtr("incomplete") + resp.IncompleteDetails = &ResponseIncompleteDetails{Reason: "max_output_tokens"} + case "content_filter": + resp.Status = lo.ToPtr("incomplete") + resp.IncompleteDetails = &ResponseIncompleteDetails{Reason: "content_filter"} case "tool_calls": resp.Status = lo.ToPtr("completed") + resp.IncompleteDetails = nil case "error": resp.Status = lo.ToPtr("failed") + resp.IncompleteDetails = nil } } } diff --git a/llm/transformer/openai/responses/inbound_stream.go b/llm/transformer/openai/responses/inbound_stream.go index 8ac9b8ea2..6809e32dc 100644 --- a/llm/transformer/openai/responses/inbound_stream.go +++ b/llm/transformer/openai/responses/inbound_stream.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "log/slog" + "sort" "strings" "github.com/samber/lo" @@ -46,6 +47,7 @@ type responsesInboundStream struct { hasContentPartStarted bool hasFinished bool responseCompleted bool + finishReason string pendingAnnotations []llm.Annotation // Response metadata @@ -72,6 +74,7 @@ type responsesInboundStream struct { currentToolCallIdx int toolCallItemStarted map[int]bool toolCallOutputIndex map[int]int // Maps tool call index to output index + toolCallItemID map[int]string // Response accumulation using streamAggregator usage *llm.Usage @@ -128,21 +131,8 @@ func (s *responsesInboundStream) Next() bool { // Try to get the next chunk from source if !s.source.Next() { if s.err == nil && !s.errorEventEmitted && s.source.Err() == nil && s.hasFinished && !s.responseCompleted { - s.responseCompleted = true - s.aggregator.status = "completed" - response := s.aggregator.buildResponse() - if s.usage != nil { - response.Usage = ConvertLLMUsageToResponsesUsage(s.usage) - } - if calls := getResponseWebSearchCallsFromMetadata(s.transformerMetadata); len(calls) > 0 { - response.Output = append(append([]Item(nil), calls...), response.Output...) - } - - if err := s.enqueueEvent(&StreamEvent{ - Type: StreamEventTypeResponseCompleted, - Response: response, - }); err != nil { - s.err = fmt.Errorf("failed to enqueue response.completed event: %w", err) + if err := s.enqueueTerminalResponse(); err != nil { + s.err = err return false } @@ -294,6 +284,7 @@ func (s *responsesInboundStream) Next() bool { // Handle finish reason if choice.FinishReason != nil && !s.hasFinished { s.hasFinished = true + s.finishReason = *choice.FinishReason if err := s.flushPendingReasoning(); err != nil { s.err = err @@ -316,23 +307,9 @@ func (s *responsesInboundStream) Next() bool { // Handle final usage chunk and complete response if chunk.Usage != nil && s.hasFinished && !s.responseCompleted { - s.responseCompleted = true s.usage = chunk.Usage - - // Build final response using aggregator - s.aggregator.status = "completed" - response := s.aggregator.buildResponse() - response.Usage = ConvertLLMUsageToResponsesUsage(s.usage) - if calls := getResponseWebSearchCallsFromMetadata(s.transformerMetadata); len(calls) > 0 { - response.Output = append(append([]Item(nil), calls...), response.Output...) - } - - err := s.enqueueEvent(&StreamEvent{ - Type: StreamEventTypeResponseCompleted, - Response: response, - }) - if err != nil { - s.err = fmt.Errorf("failed to enqueue response.completed event: %w", err) + if err := s.enqueueTerminalResponse(); err != nil { + s.err = err return false } } @@ -341,6 +318,41 @@ func (s *responsesInboundStream) Next() bool { return s.Next() } +// enqueueTerminalResponse maps Chat finish reasons to Responses terminal states. +func (s *responsesInboundStream) enqueueTerminalResponse() error { + status := "completed" + eventType := StreamEventTypeResponseCompleted + switch s.finishReason { + case "length", "content_filter": + status = "incomplete" + eventType = StreamEventTypeResponseIncomplete + case "error": + status = "failed" + eventType = StreamEventTypeResponseFailed + } + + s.responseCompleted = true + s.aggregator.status = status + response := s.aggregator.buildResponse() + if status == "incomplete" { + reason := "max_output_tokens" + if s.finishReason == "content_filter" { + reason = "content_filter" + } + response.IncompleteDetails = &ResponseIncompleteDetails{Reason: reason} + } + if s.usage != nil { + response.Usage = ConvertLLMUsageToResponsesUsage(s.usage) + } + if calls := getResponseWebSearchCallsFromMetadata(s.transformerMetadata); len(calls) > 0 { + response.Output = append(append([]Item(nil), calls...), response.Output...) + } + if err := s.enqueueEvent(&StreamEvent{Type: eventType, Response: response}); err != nil { + return fmt.Errorf("failed to enqueue %s event: %w", eventType, err) + } + return nil +} + func (s *responsesInboundStream) mergeTransformerMetadata(metadata map[string]any) { if len(metadata) == 0 { return @@ -689,6 +701,9 @@ func (s *responsesInboundStream) handleToolCalls(toolCalls []llm.ToolCall) error if s.toolCallOutputIndex == nil { s.toolCallOutputIndex = make(map[int]int) } + if s.toolCallItemID == nil { + s.toolCallItemID = make(map[int]string) + } for _, tc := range toolCalls { toolCallIndex := tc.Index @@ -699,9 +714,31 @@ func (s *responsesInboundStream) handleToolCalls(toolCalls []llm.ToolCall) error return err } } + state := s.toolCalls[toolCallIndex] + stateKind := responsesToolCallKind(state) + deltaKind := responsesToolCallKind(&tc) + if stateKind != deltaKind { + return fmt.Errorf("tool call index %d changed type from %s to %s", toolCallIndex, stateKind, deltaKind) + } + if state.ID == "" && tc.ID != "" { + state.ID = tc.ID + if state.ResponseCustomToolCall != nil { + state.ResponseCustomToolCall.CallID = tc.ID + } + if state.ResponseToolSearchCall != nil { + state.ResponseToolSearchCall.CallID = tc.ID + } + } + if state.ResponseCustomToolCall != nil && state.ResponseCustomToolCall.Name == "" && tc.ResponseCustomToolCall != nil { + state.ResponseCustomToolCall.Name = tc.ResponseCustomToolCall.Name + } // Process delta based on tool type switch { + case tc.ResponseToolSearchCall != nil: + if err := s.handleToolSearchCallDelta(tc); err != nil { + return err + } case tc.ResponseCustomToolCall != nil: if err := s.handleCustomToolCallDelta(tc); err != nil { return err @@ -716,6 +753,18 @@ func (s *responsesInboundStream) handleToolCalls(toolCalls []llm.ToolCall) error return nil } +// responsesToolCallKind returns the lifecycle kind represented by one unified tool call. +func responsesToolCallKind(call *llm.ToolCall) string { + switch { + case call.ResponseToolSearchCall != nil: + return "tool_search" + case call.ResponseCustomToolCall != nil: + return "custom" + default: + return "function" + } +} + func (s *responsesInboundStream) initToolCall(tc llm.ToolCall) error { toolCallIndex := tc.Index @@ -723,15 +772,22 @@ func (s *responsesInboundStream) initToolCall(tc llm.ToolCall) error { return err } - if err := s.closeCurrentOutputItem(); err != nil { - return err + var customCall *llm.ResponseCustomToolCall + if tc.ResponseCustomToolCall != nil { + customCall = &llm.ResponseCustomToolCall{CallID: tc.ResponseCustomToolCall.CallID, Name: tc.ResponseCustomToolCall.Name} + } + var toolSearchCall *llm.ResponseToolSearchCall + if tc.ResponseToolSearchCall != nil { + toolSearchCall = &llm.ResponseToolSearchCall{CallID: tc.ResponseToolSearchCall.CallID, Execution: tc.ResponseToolSearchCall.Execution} } s.toolCalls[toolCallIndex] = &llm.ToolCall{ Index: toolCallIndex, ID: tc.ID, Type: tc.Type, - ResponseCustomToolCall: tc.ResponseCustomToolCall, + ResponseCustomToolCall: customCall, + ResponseToolSearchCall: toolSearchCall, + TransformerMetadata: tc.TransformerMetadata, Function: llm.FunctionCall{ Name: tc.Function.Name, Namespace: tc.Function.Namespace, @@ -745,6 +801,14 @@ func (s *responsesInboundStream) initToolCall(tc llm.ToolCall) error { } switch { + case tc.ResponseToolSearchCall != nil: + item := &Item{ + ID: itemID, Type: "tool_search_call", Status: lo.ToPtr("in_progress"), + CallID: tc.ResponseToolSearchCall.CallID, Execution: tc.ResponseToolSearchCall.Execution, + } + if err := s.enqueueEvent(&StreamEvent{Type: StreamEventTypeOutputItemAdded, OutputIndex: s.outputIndex, Item: item}); err != nil { + return fmt.Errorf("failed to enqueue output_item.added event: %w", err) + } case tc.ResponseCustomToolCall != nil: item := &Item{ ID: itemID, @@ -786,6 +850,7 @@ func (s *responsesInboundStream) initToolCall(tc llm.ToolCall) error { s.toolCallItemStarted[toolCallIndex] = true s.toolCallOutputIndex[toolCallIndex] = s.outputIndex + s.toolCallItemID[toolCallIndex] = itemID s.currentItemID = itemID s.outputIndex++ @@ -797,10 +862,7 @@ func (s *responsesInboundStream) handleFunctionCallDelta(tc llm.ToolCall) error s.toolCalls[toolCallIndex].Function.Arguments += tc.Function.Arguments if tc.Function.Arguments != "" { - itemID := s.toolCalls[toolCallIndex].ID - if itemID == "" { - itemID = s.currentItemID - } + itemID := s.toolCallItemID[toolCallIndex] err := s.enqueueEvent(&StreamEvent{ Type: StreamEventTypeFunctionCallArgumentsDelta, @@ -819,13 +881,20 @@ func (s *responsesInboundStream) handleFunctionCallDelta(tc llm.ToolCall) error func (s *responsesInboundStream) handleCustomToolCallDelta(tc llm.ToolCall) error { toolCallIndex := tc.Index - s.toolCalls[toolCallIndex].ResponseCustomToolCall.Input += tc.ResponseCustomToolCall.Input + state := s.toolCalls[toolCallIndex] + state.ResponseCustomToolCall.Input += tc.ResponseCustomToolCall.Input + if wrapped, _ := tc.TransformerMetadata["openai_responses_chat_wrapped_custom"].(bool); wrapped { + if state.TransformerMetadata == nil { + state.TransformerMetadata = map[string]any{} + } + state.TransformerMetadata["openai_responses_chat_wrapped_custom"] = true + } + if wrapped, _ := state.TransformerMetadata["openai_responses_chat_wrapped_custom"].(bool); wrapped { + return nil + } if tc.ResponseCustomToolCall.Input != "" { - itemID := s.toolCalls[toolCallIndex].ID - if itemID == "" { - itemID = s.currentItemID - } + itemID := s.toolCallItemID[toolCallIndex] err := s.enqueueEvent(&StreamEvent{ Type: StreamEventTypeCustomToolCallInputDelta, @@ -841,6 +910,25 @@ func (s *responsesInboundStream) handleCustomToolCallDelta(tc llm.ToolCall) erro return nil } +// handleToolSearchCallDelta emits lifecycle events for a client tool-search call. +func (s *responsesInboundStream) handleToolSearchCallDelta(tc llm.ToolCall) error { + toolCallIndex := tc.Index + state := s.toolCalls[toolCallIndex].ResponseToolSearchCall + state.Arguments += tc.ResponseToolSearchCall.Arguments + if tc.ResponseToolSearchCall.Arguments == "" { + return nil + } + itemID := s.toolCallItemID[toolCallIndex] + if err := s.enqueueEvent(&StreamEvent{ + Type: StreamEventTypeFunctionCallArgumentsDelta, ItemID: &itemID, + OutputIndex: s.toolCallOutputIndex[toolCallIndex], ContentIndex: lo.ToPtr(0), + Delta: tc.ResponseToolSearchCall.Arguments, + }); err != nil { + return fmt.Errorf("failed to enqueue tool_search_call arguments delta: %w", err) + } + return nil +} + func (s *responsesInboundStream) closeReasoningItem() error { if !s.hasReasoningItemStarted { return nil @@ -1031,20 +1119,68 @@ func (s *responsesInboundStream) closeCurrentOutputItem() error { } // Close any open tool call items - for idx, tc := range s.toolCalls { + toolCallIndexes := lo.Keys(s.toolCalls) + sort.Ints(toolCallIndexes) + for _, idx := range toolCallIndexes { + tc := s.toolCalls[idx] if !s.toolCallItemStarted[idx] { continue } - itemID := tc.ID + itemID := s.toolCallItemID[idx] + if itemID == "" { + itemID = tc.ID + } if itemID == "" { itemID = s.currentItemID } switch { + case tc.ResponseToolSearchCall != nil: + arguments := tc.ResponseToolSearchCall.Arguments + if err := s.enqueueEvent(&StreamEvent{ + Type: StreamEventTypeFunctionCallArgumentsDone, ItemID: &itemID, + OutputIndex: s.toolCallOutputIndex[idx], Arguments: arguments, + }); err != nil { + return fmt.Errorf("failed to enqueue tool_search_call arguments done: %w", err) + } + item := Item{ + ID: itemID, Type: "tool_search_call", Status: lo.ToPtr("completed"), + CallID: tc.ResponseToolSearchCall.CallID, Execution: tc.ResponseToolSearchCall.Execution, + Arguments: arguments, + } + if err := s.enqueueEvent(&StreamEvent{Type: StreamEventTypeOutputItemDone, OutputIndex: s.toolCallOutputIndex[idx], Item: &item}); err != nil { + return fmt.Errorf("failed to enqueue output_item.done event: %w", err) + } case tc.ResponseCustomToolCall != nil: // Custom tool call - emit custom_tool_call_input.done then output_item.done fullInput := tc.ResponseCustomToolCall.Input + if wrapped, _ := tc.TransformerMetadata["openai_responses_chat_wrapped_custom"].(bool); wrapped { + var input struct { + Input *string `json:"input"` + } + if err := json.Unmarshal([]byte(fullInput), &input); err != nil { + slog.WarnContext(s.ctx, "failed to unwrap Chat custom tool input", + slog.String("call_id", tc.ResponseCustomToolCall.CallID), + slog.Any("error", err)) + // Some compatible providers ignore the wrapper schema and emit raw + // custom input. Preserve it, matching the non-streaming fallback. + } else if input.Input == nil { + slog.WarnContext(s.ctx, "failed to unwrap Chat custom tool input", + slog.String("call_id", tc.ResponseCustomToolCall.CallID), + slog.String("error", "missing input field")) + } else { + fullInput = *input.Input + } + if fullInput != "" { + if err := s.enqueueEvent(&StreamEvent{ + Type: StreamEventTypeCustomToolCallInputDelta, ItemID: &itemID, + OutputIndex: s.toolCallOutputIndex[idx], Delta: fullInput, + }); err != nil { + return fmt.Errorf("failed to enqueue custom_tool_call_input.delta event: %w", err) + } + } + } err := s.enqueueEvent(&StreamEvent{ Type: StreamEventTypeCustomToolCallInputDone, diff --git a/llm/transformer/openai/responses/inbound_stream_test.go b/llm/transformer/openai/responses/inbound_stream_test.go index 8ceed5560..9aba6486a 100644 --- a/llm/transformer/openai/responses/inbound_stream_test.go +++ b/llm/transformer/openai/responses/inbound_stream_test.go @@ -1,8 +1,11 @@ package responses import ( + "bytes" "encoding/json" "errors" + "fmt" + "log/slog" "testing" "github.com/google/go-cmp/cmp" @@ -88,6 +91,9 @@ func TestInboundTransformer_StreamTransformation_WithTestData(t *testing.T) { } require.NoError(t, transformedStream.Err()) + if tt.inputStreamFile == "llm-tool-2.stream.jsonl" { + assertParallelFunctionCallLifecycle(t, actualEvents) + } // Verify event count require.Equal(t, len(expectedEvents), len(actualEvents), "Event count should match expected") @@ -142,6 +148,141 @@ func TestInboundTransformer_StreamTransformation_WithTestData(t *testing.T) { } } +func assertParallelFunctionCallLifecycle(t *testing.T, events []StreamEvent) { + t.Helper() + + type lifecycle struct { + addedCount int + deltaArguments string + lastDeltaIndex int + argumentsDone int + outputDone int + arguments string + argumentsIndex int + outputIndex int + } + calls := map[int]*lifecycle{2: {}, 3: {}} + + for i := range events { + event := events[i] + call, tracked := calls[event.OutputIndex] + if !tracked { + continue + } + switch event.Type { + case StreamEventTypeOutputItemAdded: + if event.Item != nil && event.Item.Type == "function_call" { + call.addedCount++ + } + case StreamEventTypeFunctionCallArgumentsDelta: + call.deltaArguments += event.Delta + call.lastDeltaIndex = i + case StreamEventTypeFunctionCallArgumentsDone: + call.argumentsDone++ + call.arguments = event.Arguments + call.argumentsIndex = i + case StreamEventTypeOutputItemDone: + if event.Item != nil && event.Item.Type == "function_call" { + call.outputDone++ + call.outputIndex = i + require.Equal(t, event.Item.Arguments, call.arguments) + } + } + } + + wantArguments := map[int]string{2: `{"expression":"25 * 4"}`, 3: `{"location":"Tokyo"}`} + for outputIndex, call := range calls { + require.Equal(t, 1, call.addedCount, "output index %d added count", outputIndex) + require.Equal(t, wantArguments[outputIndex], call.deltaArguments, "output index %d delta arguments", outputIndex) + require.Equal(t, 1, call.argumentsDone, "output index %d arguments.done count", outputIndex) + require.Equal(t, 1, call.outputDone, "output index %d output_item.done count", outputIndex) + require.Equal(t, wantArguments[outputIndex], call.arguments, "output index %d completed arguments", outputIndex) + require.Greater(t, call.argumentsIndex, call.lastDeltaIndex, "output index %d arguments.done ordering", outputIndex) + require.Greater(t, call.outputIndex, call.argumentsIndex, "output index %d output_item.done ordering", outputIndex) + } +} + +func TestInboundTransformer_TransformStream_UsesStableItemIDsForParallelToolCallDeltas(t *testing.T) { + tests := []struct { + name string + deltaID func(int) string + }{ + {name: "missing IDs", deltaID: func(int) string { return "" }}, + {name: "delayed IDs", deltaID: func(index int) string { return fmt.Sprintf("late_call_%d", index) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := streams.SliceStream([]*llm.Response{ + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{ + {Index: 0, Function: llm.FunctionCall{Name: "lookup"}}, + {Index: 1, ResponseCustomToolCall: &llm.ResponseCustomToolCall{CallID: "custom_call", Name: "apply_patch"}}, + {Index: 2, ResponseToolSearchCall: &llm.ResponseToolSearchCall{CallID: "search_call", Execution: "client"}}, + }}}}}, + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{ + {ID: tt.deltaID(0), Index: 0, Function: llm.FunctionCall{Arguments: `{"query":"docs"}`}}, + {ID: tt.deltaID(1), Index: 1, ResponseCustomToolCall: &llm.ResponseCustomToolCall{Input: "patch"}}, + {ID: tt.deltaID(2), Index: 2, ResponseToolSearchCall: &llm.ResponseToolSearchCall{Arguments: `{"query":"agents"}`}}, + }}}}}, + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{}, FinishReason: lo.ToPtr("tool_calls")}}}, + llm.DoneResponse, + }) + + stream, err := NewInboundTransformer().TransformStream(t.Context(), source) + require.NoError(t, err) + addedItemIDs := make(map[int]string) + var deltaEvents []StreamEvent + for stream.Next() { + var event StreamEvent + require.NoError(t, json.Unmarshal(stream.Current().Data, &event)) + if event.Type == StreamEventTypeOutputItemAdded && event.Item != nil { + addedItemIDs[event.OutputIndex] = event.Item.ID + } + if event.Type == StreamEventTypeFunctionCallArgumentsDelta || event.Type == StreamEventTypeCustomToolCallInputDelta { + deltaEvents = append(deltaEvents, event) + } + } + require.NoError(t, stream.Err()) + require.Len(t, addedItemIDs, 3) + require.Len(t, deltaEvents, 3) + deltaCounts := make(map[int]int, len(deltaEvents)) + for _, event := range deltaEvents { + deltaCounts[event.OutputIndex]++ + } + require.Equal(t, map[int]int{0: 1, 1: 1, 2: 1}, deltaCounts) + for _, event := range deltaEvents { + require.NotNil(t, event.ItemID) + require.Equal(t, addedItemIDs[event.OutputIndex], *event.ItemID, "output index %d item ID", event.OutputIndex) + } + }) + } +} + +func TestInboundTransformer_TransformStream_ClosesParallelToolCallsInIndexOrder(t *testing.T) { + source := streams.SliceStream([]*llm.Response{ + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{ + {ID: "call_2", Index: 2, Function: llm.FunctionCall{Name: "third"}}, + {ID: "call_0", Index: 0, Function: llm.FunctionCall{Name: "first"}}, + {ID: "call_1", Index: 1, Function: llm.FunctionCall{Name: "second"}}, + }}}}}, + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{}, FinishReason: lo.ToPtr("tool_calls")}}}, + llm.DoneResponse, + }) + + stream, err := NewInboundTransformer().TransformStream(t.Context(), source) + require.NoError(t, err) + var doneCallIDs []string + for stream.Next() { + var event StreamEvent + require.NoError(t, json.Unmarshal(stream.Current().Data, &event)) + if event.Type == StreamEventTypeOutputItemDone && event.Item != nil && event.Item.Type == "function_call" { + doneCallIDs = append(doneCallIDs, event.Item.CallID) + } + } + require.NoError(t, stream.Err()) + require.Equal(t, []string{"call_0", "call_1", "call_2"}, doneCallIDs) +} + func TestInboundTransformer_TransformStream_KeepsResponsesReasoningItemsSeparate(t *testing.T) { trans := NewInboundTransformer() @@ -236,6 +377,146 @@ func TestInboundTransformer_TransformStream_KeepsResponsesReasoningItemsSeparate require.Equal(t, "gAAAA_done_2", lo.FromPtr(lastEvent.Response.Output[1].EncryptedContent)) } +func TestInboundTransformer_TransformStream_EmitsAdaptedSpecialToolCalls(t *testing.T) { + wrappedMetadata := map[string]any{"openai_responses_chat_wrapped_custom": true} + source := streams.SliceStream([]*llm.Response{ + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{Role: "assistant"}}}}, + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{ + {ID: "call_custom", Index: 0, ResponseCustomToolCall: &llm.ResponseCustomToolCall{CallID: "call_custom", Name: "apply_patch", Input: `{"input":"*** Begin`}, TransformerMetadata: wrappedMetadata}, + {ID: "call_search", Index: 1, ResponseToolSearchCall: &llm.ResponseToolSearchCall{CallID: "call_search", Execution: "client", Arguments: `{"query":"agents"}`}}, + }}}}}, + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{ + {Index: 0, ResponseCustomToolCall: &llm.ResponseCustomToolCall{Input: ` Patch"}`}}, + }}}}}, + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{}, FinishReason: lo.ToPtr("tool_calls")}}}, + llm.DoneResponse, + }) + + stream, err := NewInboundTransformer().TransformStream(t.Context(), source) + require.NoError(t, err) + var events []StreamEvent + for stream.Next() { + var event StreamEvent + require.NoError(t, json.Unmarshal(stream.Current().Data, &event)) + events = append(events, event) + } + require.NoError(t, stream.Err()) + + var customDone, searchDone *Item + for i := range events { + event := events[i] + if event.Type == StreamEventTypeCustomToolCallInputDelta { + require.NotContains(t, event.Delta, `{"input"`) + } + if event.Type != StreamEventTypeOutputItemDone || event.Item == nil { + continue + } + switch event.Item.Type { + case "custom_tool_call": + customDone = event.Item + case "tool_search_call": + searchDone = event.Item + } + } + require.NotNil(t, customDone) + require.Equal(t, "*** Begin Patch", lo.FromPtr(customDone.Input)) + require.NotNil(t, searchDone) + require.Equal(t, "client", searchDone.Execution) + require.JSONEq(t, `{"query":"agents"}`, searchDone.Arguments) +} + +func TestInboundTransformer_TransformStream_RejectsToolCallTypeChanges(t *testing.T) { + plain := llm.ToolCall{ID: "call_1", Index: 0, Type: "function", Function: llm.FunctionCall{Name: "lookup"}} + custom := llm.ToolCall{ID: "call_1", Index: 0, ResponseCustomToolCall: &llm.ResponseCustomToolCall{CallID: "call_1", Name: "apply_patch"}} + toolSearch := llm.ToolCall{ID: "call_1", Index: 0, ResponseToolSearchCall: &llm.ResponseToolSearchCall{CallID: "call_1", Execution: "client"}} + tests := []struct { + name string + first llm.ToolCall + second llm.ToolCall + want string + }{ + {name: "function to custom", first: plain, second: custom, want: "from function to custom"}, + {name: "function to tool search", first: plain, second: toolSearch, want: "from function to tool_search"}, + {name: "custom to function", first: custom, second: plain, want: "from custom to function"}, + {name: "tool search to function", first: toolSearch, second: plain, want: "from tool_search to function"}, + {name: "custom to tool search", first: custom, second: toolSearch, want: "from custom to tool_search"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := streams.SliceStream([]*llm.Response{ + {ID: "resp_changed_type", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{tt.first}}}}}, + {ID: "resp_changed_type", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{tt.second}}}}}, + llm.DoneResponse, + }) + stream, err := NewInboundTransformer().TransformStream(t.Context(), source) + require.NoError(t, err) + for stream.Next() { + _ = stream.Current() + } + require.ErrorContains(t, stream.Err(), "tool call index 0 changed type "+tt.want) + }) + } +} + +func TestInboundTransformer_TransformStream_DegradesMalformedWrappedCustomInput(t *testing.T) { + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + wrappedMetadata := map[string]any{"openai_responses_chat_wrapped_custom": true} + source := streams.SliceStream([]*llm.Response{ + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{{ + ID: "call_custom", Index: 0, + ResponseCustomToolCall: &llm.ResponseCustomToolCall{CallID: "call_custom", Name: "apply_patch", Input: `{"input":"patch"`}, + TransformerMetadata: wrappedMetadata, + }}}}}}, + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{}, FinishReason: lo.ToPtr("tool_calls")}}}, + llm.DoneResponse, + }) + + stream, err := NewInboundTransformer().TransformStream(t.Context(), source) + require.NoError(t, err) + var encodedEvents []string + for stream.Next() { + encodedEvents = append(encodedEvents, string(stream.Current().Data)) + } + require.NoError(t, stream.Err()) + require.Contains(t, logs.String(), "failed to unwrap Chat custom tool input") + require.Contains(t, logs.String(), "call_custom") + for _, event := range encodedEvents { + require.NotContains(t, event, `{"input":"patch"`) + } +} + +func TestInboundTransformer_TransformStream_WarnsOnMissingWrappedCustomInput(t *testing.T) { + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + wrappedMetadata := map[string]any{"openai_responses_chat_wrapped_custom": true} + source := streams.SliceStream([]*llm.Response{ + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{ToolCalls: []llm.ToolCall{{ + ID: "call_custom", Index: 0, + ResponseCustomToolCall: &llm.ResponseCustomToolCall{CallID: "call_custom", Name: "apply_patch", Input: `{}`}, + TransformerMetadata: wrappedMetadata, + }}}}}}, + {ID: "resp_tools", Model: "glm-5.2", Choices: []llm.Choice{{Index: 0, Delta: &llm.Message{}, FinishReason: lo.ToPtr("tool_calls")}}}, + llm.DoneResponse, + }) + + stream, err := NewInboundTransformer().TransformStream(t.Context(), source) + require.NoError(t, err) + for stream.Next() { + _ = stream.Current() + } + require.NoError(t, stream.Err()) + require.Contains(t, logs.String(), "failed to unwrap Chat custom tool input") + require.Contains(t, logs.String(), "missing input field") +} + func TestInboundTransformer_TransformStream_ReplacesItemScopedProvisionalSignature(t *testing.T) { trans := NewInboundTransformer() stream, err := trans.TransformStream(t.Context(), streams.SliceStream([]*llm.Response{ diff --git a/llm/transformer/openai/responses/inbound_test.go b/llm/transformer/openai/responses/inbound_test.go index 84e4ef684..7bdeab7a0 100644 --- a/llm/transformer/openai/responses/inbound_test.go +++ b/llm/transformer/openai/responses/inbound_test.go @@ -20,6 +20,134 @@ func TestNewInboundTransformer(t *testing.T) { require.NotNil(t, transformer) } +func TestInboundTransformer_PromotesAdditionalAndToolSearchTools(t *testing.T) { + transformer := NewInboundTransformer() + request, err := transformer.TransformRequest(context.Background(), &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5", + "input":[ + {"type":"additional_tools","role":"developer","tools":[ + {"type":"custom","name":"exec","description":"Run code"}, + {"type":"namespace","name":"collaboration","tools":[ + {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{}}} + ]} + ]}, + {"type":"tool_search_call","execution":"client","call_id":"call_search","arguments":{"query":"agents"}}, + {"type":"tool_search_output","execution":"client","call_id":"call_search","tools":[ + {"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}} + ]}, + {"role":"user","type":"message","content":[{"type":"input_text","text":"continue"}]} + ], + "tools":[{"type":"tool_search","execution":"client","description":"Find tools","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}] + }`)}) + require.NoError(t, err) + require.Len(t, request.Tools, 4) + require.Equal(t, llm.ToolTypeResponsesToolSearch, request.Tools[0].Type) + require.Equal(t, llm.ToolTypeResponsesCustomTool, request.Tools[1].Type) + require.Equal(t, "collaboration", request.Tools[2].Function.Namespace) + require.Equal(t, "collaboration__spawn_agent", request.Tools[2].Function.Name) + require.Equal(t, "additional_tools", request.Tools[2].ResponsesOrigin) + require.Equal(t, "send_message", request.Tools[3].Function.Name) + require.Equal(t, "tool_search_output", request.Tools[3].ResponsesOrigin) + + require.Len(t, request.Messages, 3) + require.NotNil(t, request.Messages[0].ToolCalls[0].ResponseToolSearchCall) + require.Equal(t, "call_search", lo.FromPtr(request.Messages[1].ToolCallID)) + require.JSONEq(t, `[{"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}}]`, *request.Messages[1].Content.Content) +} + +func TestInboundTransformer_EncodesEmptyToolSearchOutputAsArray(t *testing.T) { + transformer := NewInboundTransformer() + request, err := transformer.TransformRequest(context.Background(), &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5", + "input":[ + {"type":"tool_search_call","execution":"client","call_id":"call_search","arguments":{"query":"agents"}}, + {"type":"tool_search_output","execution":"client","call_id":"call_search"} + ] + }`)}) + require.NoError(t, err) + require.Len(t, request.Messages, 2) + require.Equal(t, "[]", lo.FromPtr(request.Messages[1].Content.Content)) +} + +func TestInboundTransformer_PromotesFutureClientFunctionLikeTools(t *testing.T) { + transformer := NewInboundTransformer() + request, err := transformer.TransformRequest(context.Background(), &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5", + "input":[{"type":"additional_tools","role":"developer","tools":[{ + "type":"future_client_tool","name":"later_lookup","description":"Lookup later", + "execution":"client","parameters":{"type":"object","properties":{"id":{"type":"string"}}} + }]}], + "tools":[{ + "type":"future_client_tool","name":"lookup","description":"Lookup", + "execution":"client","parameters":{"type":"object","properties":{"query":{"type":"string"}}} + }] + }`)}) + require.NoError(t, err) + require.Len(t, request.Tools, 2) + require.Equal(t, llm.ToolTypeFunction, request.Tools[0].Type) + require.Equal(t, "lookup", request.Tools[0].Function.Name) + require.Equal(t, "raw_tool", request.Tools[0].ResponsesOrigin) + require.JSONEq(t, `{"type":"object","properties":{"query":{"type":"string"}}}`, string(request.Tools[0].Function.Parameters)) + require.Equal(t, llm.ToolTypeFunction, request.Tools[1].Type) + require.Equal(t, "later_lookup", request.Tools[1].Function.Name) + require.Equal(t, "additional_tools", request.Tools[1].ResponsesOrigin) +} + +func TestInboundTransformer_PreservesUnsupportedFutureToolForExplicitRejection(t *testing.T) { + transformer := NewInboundTransformer() + request, err := transformer.TransformRequest(context.Background(), &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5","input":"hello","tools":[ + {"type":"future_server_tool","name":"hosted","execution":"server"}, + {"type":"future_unknown_tool","name":"unknown","parameters":{"type":"object"}} + ] + }`)}) + require.NoError(t, err) + require.Len(t, request.Tools, 2) + require.Equal(t, llm.ToolTypeResponsesOpaqueTool, request.Tools[0].Type) + require.Equal(t, "future_server_tool", request.Tools[0].ResponseOpaqueTool.SourceType) + require.Equal(t, llm.ToolTypeResponsesOpaqueTool, request.Tools[1].Type) + require.Equal(t, "future_unknown_tool", request.Tools[1].ResponseOpaqueTool.SourceType) +} + +func TestInboundTransformer_DoesNotInferUnknownNamespaceExecutionOwner(t *testing.T) { + transformer := NewInboundTransformer() + request, err := transformer.TransformRequest(context.Background(), &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5","input":"hello","tools":[{ + "type":"namespace","name":"hosted","tools":[ + {"type":"future_hosted_tool","name":"implicit","parameters":{"type":"object"}}, + {"type":"future_client_tool","name":"explicit","execution":"client","parameters":{"type":"object"}} + ] + }] + }`)}) + require.NoError(t, err) + require.Len(t, request.Tools, 2) + require.Equal(t, llm.ToolTypeResponsesOpaqueTool, request.Tools[0].Type) + require.Equal(t, "future_hosted_tool", request.Tools[0].ResponseOpaqueTool.SourceType) + require.Equal(t, llm.ToolTypeFunction, request.Tools[1].Type) + require.Equal(t, "hosted", request.Tools[1].Function.Namespace) + require.Equal(t, "hosted__explicit", request.Tools[1].Function.Name) +} + +func TestInboundTransformer_EmitsResponsesSpecialToolCalls(t *testing.T) { + transformer := NewInboundTransformer() + result, err := transformer.TransformResponse(context.Background(), &llm.Response{ + ID: "resp_1", Model: "glm-5.2", + Choices: []llm.Choice{{Message: &llm.Message{Role: "assistant", ToolCalls: []llm.ToolCall{ + {ID: "call_custom", ResponseCustomToolCall: &llm.ResponseCustomToolCall{CallID: "call_custom", Name: "apply_patch", Input: "*** Begin Patch"}}, + {ID: "call_search", ResponseToolSearchCall: &llm.ResponseToolSearchCall{CallID: "call_search", Execution: "client", Arguments: `{"query":"agents"}`}}, + }}}}, + }) + require.NoError(t, err) + var response Response + require.NoError(t, json.Unmarshal(result.Body, &response)) + require.Len(t, response.Output, 2) + require.Equal(t, "custom_tool_call", response.Output[0].Type) + require.Equal(t, "*** Begin Patch", *response.Output[0].Input) + require.Equal(t, "tool_search_call", response.Output[1].Type) + require.Equal(t, "client", response.Output[1].Execution) + require.JSONEq(t, `{"query":"agents"}`, response.Output[1].Arguments) +} + func TestInboundTransformer_TransformRequest(t *testing.T) { trans := NewInboundTransformer() @@ -200,7 +328,7 @@ func TestInboundTransformer_TransformRequest(t *testing.T) { }, expectError: false, validate: func(t *testing.T, result *llm.Request) { - require.Len(t, result.Tools, 3) + require.Len(t, result.Tools, 4) namespaceTool := result.Tools[0] require.Equal(t, "function", namespaceTool.Type) @@ -211,7 +339,9 @@ func TestInboundTransformer_TransformRequest(t *testing.T) { require.True(t, *namespaceTool.Function.Strict) require.Equal(t, "mcp__codebase_memory_mcp__get_project", result.Tools[1].Function.Name) - require.Equal(t, "get_weather", result.Tools[2].Function.Name) + require.Equal(t, llm.ToolTypeResponsesOpaqueTool, result.Tools[2].Type) + require.Equal(t, "web_search", result.Tools[2].ResponseOpaqueTool.SourceType) + require.Equal(t, "get_weather", result.Tools[3].Function.Name) }, }, { @@ -242,7 +372,9 @@ func TestInboundTransformer_TransformRequest(t *testing.T) { }, expectError: false, validate: func(t *testing.T, result *llm.Request) { - require.Len(t, result.Tools, 1) + require.Len(t, result.Tools, 2) + require.Equal(t, llm.ToolTypeResponsesToolSearch, result.Tools[0].Type) + require.Equal(t, llm.ToolTypeFunction, result.Tools[1].Type) require.NotNil(t, result.ProviderExtensions) require.NotNil(t, result.ProviderExtensions.OpenAIResponses) require.NotNil(t, result.ProviderExtensions.OpenAIResponses.Request) @@ -920,6 +1052,40 @@ func TestInboundTransformer_TransformResponse(t *testing.T) { require.NoError(t, err) require.NotNil(t, resp.Status) require.Equal(t, "incomplete", *resp.Status) + require.NotNil(t, resp.IncompleteDetails) + require.Equal(t, "max_output_tokens", resp.IncompleteDetails.Reason) + }, + }, + { + name: "response with content filter finish reason", + chatResp: &llm.Response{ + ID: "chatcmpl-content-filter", + Object: "chat.completion", + Created: 1677652288, + Model: "gpt-4o", + Choices: []llm.Choice{ + { + Index: 0, + Message: &llm.Message{ + Role: "assistant", + Content: llm.MessageContent{ + Content: lo.ToPtr("Filtered response..."), + }, + }, + FinishReason: lo.ToPtr("content_filter"), + }, + }, + }, + expectError: false, + validate: func(t *testing.T, result *httpclient.Response) { + var resp Response + + err := json.Unmarshal(result.Body, &resp) + require.NoError(t, err) + require.NotNil(t, resp.Status) + require.Equal(t, "incomplete", *resp.Status) + require.NotNil(t, resp.IncompleteDetails) + require.Equal(t, "content_filter", resp.IncompleteDetails.Reason) }, }, { @@ -1597,120 +1763,107 @@ func TestConvertItemToMessage_Reasoning(t *testing.T) { require.Nil(t, result, "reasoning items should return nil from convertItemToMessage") } -func TestConvertInputToMessages_GroupsConsecutiveToolCalls(t *testing.T) { +func TestConvertInputToMessages_GroupsConsecutiveMixedToolCalls(t *testing.T) { input := &Input{Items: []Item{ - {Role: "user", Content: &Input{Text: lo.ToPtr("Run both tools.")}}, - {Type: "function_call", CallID: "call_a", Name: "first_tool", Arguments: `{}`}, - {Type: "function_call", CallID: "call_b", Name: "second_tool", Arguments: `{"value":2}`}, - {Type: "function_call_output", CallID: "call_a", Output: &Input{Text: lo.ToPtr("first result")}}, - {Type: "function_call_output", CallID: "call_b", Output: &Input{Text: lo.ToPtr("second result")}}, - {Role: "user", Content: &Input{Text: lo.ToPtr("Continue.")}}, + {Type: "function_call", CallID: "function:1", Name: "lookup", Arguments: `{"id":"42"}`}, + {Type: "custom_tool_call", CallID: "custom:2", Name: "apply_patch", Input: lo.ToPtr("patch")}, + {Type: "tool_search_call", CallID: "search:3", Execution: "client", Arguments: `{"query":"agents"}`}, }} messages, err := convertInputToMessages(input) require.NoError(t, err) - require.Len(t, messages, 5) - - require.Equal(t, "user", messages[0].Role) - require.Equal(t, "Run both tools.", lo.FromPtr(messages[0].Content.Content)) - - require.Equal(t, "assistant", messages[1].Role) - require.Len(t, messages[1].ToolCalls, 2) - require.Equal(t, "call_a", messages[1].ToolCalls[0].ID) - require.Equal(t, "first_tool", messages[1].ToolCalls[0].Function.Name) - require.Equal(t, `{}`, messages[1].ToolCalls[0].Function.Arguments) - require.Equal(t, "call_b", messages[1].ToolCalls[1].ID) - require.Equal(t, "second_tool", messages[1].ToolCalls[1].Function.Name) - require.Equal(t, `{"value":2}`, messages[1].ToolCalls[1].Function.Arguments) - - require.Equal(t, "tool", messages[2].Role) - require.Equal(t, "call_a", lo.FromPtr(messages[2].ToolCallID)) - require.Equal(t, "first result", lo.FromPtr(messages[2].Content.Content)) - require.Equal(t, "tool", messages[3].Role) - require.Equal(t, "call_b", lo.FromPtr(messages[3].ToolCallID)) - require.Equal(t, "second result", lo.FromPtr(messages[3].Content.Content)) - - require.Equal(t, "user", messages[4].Role) - require.Equal(t, "Continue.", lo.FromPtr(messages[4].Content.Content)) + require.Len(t, messages, 1) + require.Equal(t, "assistant", messages[0].Role) + require.Len(t, messages[0].ToolCalls, 3) + require.Equal(t, []string{"function:1", "custom:2", "search:3"}, []string{ + messages[0].ToolCalls[0].ID, + messages[0].ToolCalls[1].ID, + messages[0].ToolCalls[2].ID, + }) + require.Equal(t, "lookup", messages[0].ToolCalls[0].Function.Name) + require.NotNil(t, messages[0].ToolCalls[1].ResponseCustomToolCall) + require.Equal(t, "apply_patch", messages[0].ToolCalls[1].ResponseCustomToolCall.Name) + require.NotNil(t, messages[0].ToolCalls[2].ResponseToolSearchCall) + require.Equal(t, "client", messages[0].ToolCalls[2].ResponseToolSearchCall.Execution) } -func TestConvertInputToMessages_GroupsMixedToolCallTypes(t *testing.T) { - input := &Input{Items: []Item{ +func TestConvertInputToMessages_StopsToolCallGroupsAtBoundaries(t *testing.T) { + functionCall := func(callID string) Item { + return Item{Type: "function_call", CallID: callID, Name: "lookup", Arguments: `{}`} + } + customCall := func(callID string) Item { + return Item{Type: "custom_tool_call", CallID: callID, Name: "apply_patch", Input: lo.ToPtr("patch")} + } + toolSearchCall := func(callID string) Item { + return Item{Type: "tool_search_call", CallID: callID, Execution: "client", Arguments: `{}`} + } + + tests := []struct { + name string + items []Item + wantRoles []string + }{ { - Type: "function_call", - CallID: "call_function", - Name: "function_tool", - Namespace: "namespace", - Arguments: `{"query":"value"}`, + name: "function output", + items: []Item{ + functionCall("call:1"), + {Type: "function_call_output", CallID: "call:1", Output: &Input{Text: lo.ToPtr("result")}}, + functionCall("call:2"), + }, + wantRoles: []string{"assistant", "tool", "assistant"}, }, { - Type: "custom_tool_call", - CallID: "call_custom", - Name: "custom_tool", - Input: lo.ToPtr("freeform input"), + name: "custom output", + items: []Item{ + customCall("call:1"), + {Type: "custom_tool_call_output", CallID: "call:1", Output: &Input{Text: lo.ToPtr("result")}}, + functionCall("call:2"), + }, + wantRoles: []string{"assistant", "tool", "assistant"}, }, - }} - - messages, err := convertInputToMessages(input) - require.NoError(t, err) - require.Len(t, messages, 1) - require.Equal(t, "assistant", messages[0].Role) - require.Len(t, messages[0].ToolCalls, 2) - - functionCall := messages[0].ToolCalls[0] - require.Equal(t, "call_function", functionCall.ID) - require.Equal(t, "function", functionCall.Type) - require.Equal(t, "function_tool", functionCall.Function.Name) - require.Equal(t, "namespace", functionCall.Function.Namespace) - require.Equal(t, `{"query":"value"}`, functionCall.Function.Arguments) - - customCall := messages[0].ToolCalls[1] - require.Equal(t, "call_custom", customCall.ID) - require.Equal(t, llm.ToolTypeResponsesCustomTool, customCall.Type) - require.NotNil(t, customCall.ResponseCustomToolCall) - require.Equal(t, "call_custom", customCall.ResponseCustomToolCall.CallID) - require.Equal(t, "custom_tool", customCall.ResponseCustomToolCall.Name) - require.Equal(t, "freeform input", customCall.ResponseCustomToolCall.Input) -} - -func TestConvertInputToMessages_DoesNotGroupToolCallsAcrossBoundaries(t *testing.T) { - t.Run("tool output", func(t *testing.T) { - input := &Input{Items: []Item{ - {Type: "function_call", CallID: "call_a", Name: "first_tool", Arguments: `{}`}, - {Type: "function_call_output", CallID: "call_a", Output: &Input{Text: lo.ToPtr("result")}}, - {Type: "function_call", CallID: "call_b", Name: "second_tool", Arguments: `{}`}, - }} - - messages, err := convertInputToMessages(input) - require.NoError(t, err) - require.Len(t, messages, 3) - require.Equal(t, []string{"assistant", "tool", "assistant"}, []string{ - messages[0].Role, - messages[1].Role, - messages[2].Role, - }) - require.Equal(t, "call_a", messages[0].ToolCalls[0].ID) - require.Equal(t, "call_b", messages[2].ToolCalls[0].ID) - }) + { + name: "tool search output", + items: []Item{ + toolSearchCall("call:1"), + {Type: "tool_search_output", CallID: "call:1", Tools: []Tool{}}, + functionCall("call:2"), + }, + wantRoles: []string{"assistant", "tool", "assistant"}, + }, + { + name: "user message", + items: []Item{ + functionCall("call:1"), + {Type: "message", Role: "user", Content: &Input{Text: lo.ToPtr("next turn")}}, + customCall("call:2"), + }, + wantRoles: []string{"assistant", "user", "assistant"}, + }, + { + name: "reasoning", + items: []Item{ + functionCall("call:1"), + {Type: "reasoning", Summary: []ReasoningSummary{{Type: "summary_text", Text: "next turn"}}}, + customCall("call:2"), + }, + wantRoles: []string{"assistant", "assistant"}, + }, + } - t.Run("user message", func(t *testing.T) { - input := &Input{Items: []Item{ - {Type: "function_call", CallID: "call_a", Name: "first_tool", Arguments: `{}`}, - {Role: "user", Content: &Input{Text: lo.ToPtr("New turn.")}}, - {Type: "function_call", CallID: "call_b", Name: "second_tool", Arguments: `{}`}, - }} - - messages, err := convertInputToMessages(input) - require.NoError(t, err) - require.Len(t, messages, 3) - require.Equal(t, []string{"assistant", "user", "assistant"}, []string{ - messages[0].Role, - messages[1].Role, - messages[2].Role, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + messages, err := convertInputToMessages(&Input{Items: tt.items}) + require.NoError(t, err) + require.Len(t, messages, len(tt.wantRoles)) + roles := make([]string, len(messages)) + for i := range messages { + roles[i] = messages[i].Role + } + require.Equal(t, tt.wantRoles, roles) + require.Equal(t, "call:1", messages[0].ToolCalls[0].ID) + require.Equal(t, "call:2", messages[len(messages)-1].ToolCalls[0].ID) }) - require.Equal(t, "call_a", messages[0].ToolCalls[0].ID) - require.Equal(t, "call_b", messages[2].ToolCalls[0].ID) - }) + } } func TestConvertReasoningWithFollowing(t *testing.T) { diff --git a/llm/transformer/openai/responses/model.go b/llm/transformer/openai/responses/model.go index edb71e40b..5f5e4de1f 100644 --- a/llm/transformer/openai/responses/model.go +++ b/llm/transformer/openai/responses/model.go @@ -23,15 +23,19 @@ type ImageGeneration struct { } type Tool struct { - // Any of "function", "image_generation", "custom", "web_search", "namespace". + // Type identifies a Responses tool declaration, including function, custom, + // namespace, tool_search, web_search, image_generation, and future opaque types. Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` + Execution string `json:"execution,omitempty"` // This field is from variant [FunctionTool]. Parameters map[string]any `json:"parameters,omitempty"` // This field is from variant [FunctionTool]. Strict *bool `json:"strict,omitempty"` + // DeferLoading marks a function as discoverable through tool search. + DeferLoading bool `json:"defer_loading,omitempty"` // Tools holds sub-tools when Type is "namespace". Tools []Tool `json:"tools,omitempty"` @@ -518,8 +522,9 @@ type Item struct { // The ID of the item, generated by the server. ID string `json:"id,omitempty"` - // Any of "message", "input_text", "input_image", "input_audio", "output_text", "compaction", "compaction_summary", - // "function_call", "function_call_output", "custom_tool_call", "custom_tool_call_output", "image_generation_call", "reasoning", "reasoning_text". + // Type identifies a Responses input, output, or tool-catalog item, including + // messages, reasoning, function/custom/tool-search calls and outputs, + // additional_tools catalogs, compaction, and image-generation items. Type string `json:"type,omitempty"` // The annotations of the text output. @@ -564,8 +569,12 @@ type Item struct { CallID string `json:"call_id,omitempty"` Name string `json:"name,omitempty"` Namespace string `json:"namespace,omitempty"` + Execution string `json:"execution,omitempty"` Arguments string `json:"arguments,omitempty"` + // Tools is used by additional_tools and tool_search_output items. + Tools []Tool `json:"tools,omitempty"` + // Custom tool call fields (for type="custom_tool_call") // Input is the freeform input text generated by the model for custom tool calls. Input *string `json:"input,omitempty"` @@ -638,6 +647,19 @@ func (item Item) MarshalJSON() ([]byte, error) { }) } + if item.Type == "tool_search_call" { + arguments := json.RawMessage(item.Arguments) + if len(arguments) == 0 || !json.Valid(arguments) { + arguments = json.RawMessage(`{}`) + } + type toolSearchCallItem struct { + itemAlias + + Arguments json.RawMessage `json:"arguments"` + } + return json.Marshal(toolSearchCallItem{itemAlias: itemAlias(item), Arguments: arguments}) + } + if item.Type == "custom_tool_call" { type customToolCallItem struct { itemAlias diff --git a/llm/transformer/openai/responses/outbound.go b/llm/transformer/openai/responses/outbound.go index 35e79cb0b..a7751bcb3 100644 --- a/llm/transformer/openai/responses/outbound.go +++ b/llm/transformer/openai/responses/outbound.go @@ -224,6 +224,9 @@ func (t *OutboundTransformer) TransformRequest(ctx context.Context, llmReq *llm. var tools []Tool // Convert tools to Responses API format for _, item := range llmReq.Tools { + if item.ResponsesOrigin != "" { + continue + } switch item.Type { case llm.ToolTypeImageGeneration: tool := convertImageGenerationToTool(item) @@ -239,6 +242,20 @@ func (t *OutboundTransformer) TransformRequest(ctx context.Context, llmReq *llm. case llm.ToolTypeResponsesCustomTool: tool := convertCustomToTool(item) tools = append(tools, tool) + case llm.ToolTypeResponsesToolSearch: + if item.ResponseToolSearch == nil { + continue + } + parameters := map[string]any{} + if len(item.ResponseToolSearch.Parameters) > 0 { + if err := json.Unmarshal(item.ResponseToolSearch.Parameters, ¶meters); err != nil { + return nil, fmt.Errorf("failed to decode tool search parameters: %w", err) + } + } + tools = append(tools, Tool{ + Type: "tool_search", Execution: item.ResponseToolSearch.Execution, + Description: item.ResponseToolSearch.Description, Parameters: parameters, + }) case "function": tool := convertFunctionToTool(item) tools = append(tools, tool) diff --git a/llm/transformer/openai/responses/outbound_convert.go b/llm/transformer/openai/responses/outbound_convert.go index 46aac6ef4..2b4128f20 100644 --- a/llm/transformer/openai/responses/outbound_convert.go +++ b/llm/transformer/openai/responses/outbound_convert.go @@ -2,6 +2,7 @@ package responses import ( "encoding/json" + "log/slog" "strings" "unicode/utf8" @@ -106,7 +107,6 @@ func convertInputFromMessages(msgs []llm.Message, transformOptions llm.Transform var items []Item // Track tool call types so tool result messages can be encoded correctly. - // callID -> item type (function_call_output or custom_tool_call_output) toolResultItemTypeByCallID := map[string]string{} for _, msg := range msgs { @@ -128,6 +128,10 @@ func convertInputFromMessages(msgs []llm.Message, transformOptions llm.Transform if it.CallID != "" { toolResultItemTypeByCallID[it.CallID] = "custom_tool_call_output" } + case "tool_search_call": + if it.CallID != "" { + toolResultItemTypeByCallID[it.CallID] = "tool_search_output" + } } } case "tool": @@ -233,7 +237,13 @@ func convertAssistantMessage(msg llm.Message) []Item { // Handle tool calls for _, tc := range msg.ToolCalls { - if tc.ResponseCustomToolCall != nil { + if tc.ResponseToolSearchCall != nil { + toolCallItems = append(toolCallItems, Item{ + Type: "tool_search_call", CallID: tc.ResponseToolSearchCall.CallID, + Execution: tc.ResponseToolSearchCall.Execution, + Arguments: tc.ResponseToolSearchCall.Arguments, + }) + } else if tc.ResponseCustomToolCall != nil { toolCallItems = append(toolCallItems, Item{ Type: "custom_tool_call", CallID: tc.ResponseCustomToolCall.CallID, @@ -303,6 +313,30 @@ func convertAssistantMessage(msg llm.Message) []Item { } func convertToolMessageWithType(msg llm.Message, itemType string) Item { + if itemType == "tool_search_output" { + tools := []Tool{} + if msg.Content.Content != nil { + content := strings.TrimSpace(*msg.Content.Content) + if !strings.HasPrefix(content, "[") { + slog.Warn("failed to decode tool_search_output tools", + slog.String("call_id", lo.FromPtr(msg.ToolCallID)), + slog.String("error", "expected a JSON array")) + } else if err := json.Unmarshal([]byte(content), &tools); err != nil { + tools = []Tool{} + slog.Warn("failed to decode tool_search_output tools", + slog.String("call_id", lo.FromPtr(msg.ToolCallID)), + slog.Any("error", err)) + } + if tools == nil { + tools = []Tool{} + } + } + return Item{ + Type: "tool_search_output", CallID: lo.FromPtr(msg.ToolCallID), + Execution: "client", Status: lo.ToPtr("completed"), Tools: tools, + } + } + var output Input // Handle simple content first @@ -707,6 +741,16 @@ func convertOutputToMessage(output []Item, transformerMetadata map[string]any) l Input: inputStr, }, }) + case "tool_search_call": + toolCalls = append(toolCalls, llm.ToolCall{ + ID: outputItem.CallID, + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearchCall: &llm.ResponseToolSearchCall{ + CallID: outputItem.CallID, + Execution: outputItem.Execution, + Arguments: outputItem.Arguments, + }, + }) case "reasoning": var itemReasoning strings.Builder for _, summary := range outputItem.Summary { diff --git a/llm/transformer/openai/responses/outbound_convert_test.go b/llm/transformer/openai/responses/outbound_convert_test.go index b4f492acb..dfd8c913a 100644 --- a/llm/transformer/openai/responses/outbound_convert_test.go +++ b/llm/transformer/openai/responses/outbound_convert_test.go @@ -1,7 +1,9 @@ package responses import ( + "bytes" "encoding/json" + "log/slog" "testing" "github.com/samber/lo" @@ -382,6 +384,43 @@ func TestConvertToolMessage(t *testing.T) { } } +func TestConvertToolSearchOutputMalformedContentDegradesWithWarning(t *testing.T) { + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + result := convertToolMessageWithType(llm.Message{ + Role: "tool", + ToolCallID: lo.ToPtr("call_search"), + Content: llm.MessageContent{Content: lo.ToPtr(`[{"type":"function"}`)}, + }, "tool_search_output") + + require.Equal(t, "tool_search_output", result.Type) + require.NotNil(t, result.Tools) + require.Empty(t, result.Tools) + require.Contains(t, logs.String(), "failed to decode tool_search_output tools") + require.Contains(t, logs.String(), "call_search") +} + +func TestConvertToolSearchOutputNullContentDegradesWithWarning(t *testing.T) { + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + result := convertToolMessageWithType(llm.Message{ + Role: "tool", + ToolCallID: lo.ToPtr("call_search_null"), + Content: llm.MessageContent{Content: lo.ToPtr(`null`)}, + }, "tool_search_output") + + require.NotNil(t, result.Tools) + require.Empty(t, result.Tools) + require.Contains(t, logs.String(), "expected a JSON array") + require.Contains(t, logs.String(), "call_search_null") +} + // The wire shape matters as much as the struct: a tool result carrying an image // has to serialise as an output array of content parts, not as a string. func TestConvertToolMessageImageSerializesAsOutputArray(t *testing.T) { @@ -1238,6 +1277,25 @@ func TestConvertOutputToMessage(t *testing.T) { require.Equal(t, "some input", tc.ResponseCustomToolCall.Input) }, }, + { + name: "tool search call output", + output: []Item{{ + Type: "tool_search_call", + CallID: "call_search_1", + Execution: "client", + Arguments: `{"query":"agents"}`, + }}, + validate: func(t *testing.T, msg llm.Message) { + require.Len(t, msg.ToolCalls, 1) + tc := msg.ToolCalls[0] + require.Equal(t, "call_search_1", tc.ID) + require.Equal(t, llm.ToolTypeResponsesToolSearch, tc.Type) + require.NotNil(t, tc.ResponseToolSearchCall) + require.Equal(t, "call_search_1", tc.ResponseToolSearchCall.CallID) + require.Equal(t, "client", tc.ResponseToolSearchCall.Execution) + require.Equal(t, `{"query":"agents"}`, tc.ResponseToolSearchCall.Arguments) + }, + }, { name: "reasoning output with encrypted content", output: []Item{ diff --git a/llm/transformer/openai/responses/outbound_stream.go b/llm/transformer/openai/responses/outbound_stream.go index ae26cbacf..5b763bda0 100644 --- a/llm/transformer/openai/responses/outbound_stream.go +++ b/llm/transformer/openai/responses/outbound_stream.go @@ -294,6 +294,40 @@ func (s *responsesOutboundStream) transformStreamChunk(event *httpclient.StreamE }, } + case "tool_search_call": + toolCallIdx := len(s.state.toolCalls) + s.state.toolCalls[item.CallID] = &llm.ToolCall{ + ID: item.CallID, + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearchCall: &llm.ResponseToolSearchCall{ + CallID: item.CallID, + Execution: item.Execution, + Arguments: item.Arguments, + }, + } + s.state.itemToCallID[item.ID] = item.CallID + s.state.toolCallIndex[item.CallID] = toolCallIdx + + resp.Choices = []llm.Choice{ + { + Index: 0, + Delta: &llm.Message{ + ToolCalls: []llm.ToolCall{ + { + ID: item.CallID, + Type: llm.ToolTypeResponsesToolSearch, + Index: toolCallIdx, + ResponseToolSearchCall: &llm.ResponseToolSearchCall{ + CallID: item.CallID, + Execution: item.Execution, + Arguments: item.Arguments, + }, + }, + }, + }, + }, + } + default: // For other item types (e.g., message), skip - no meaningful content to emit return nil // Intentionally skip this event @@ -310,8 +344,31 @@ func (s *responsesOutboundStream) transformStreamChunk(event *httpclient.StreamE } if tc, ok := s.state.toolCalls[callID]; ok { - tc.Function.Arguments += streamEvent.Delta toolCallIdx := s.state.toolCallIndex[callID] + if tc.ResponseToolSearchCall != nil { + tc.ResponseToolSearchCall.Arguments += streamEvent.Delta + resp.Choices = []llm.Choice{ + { + Index: 0, + Delta: &llm.Message{ + ToolCalls: []llm.ToolCall{ + { + Index: toolCallIdx, + Type: llm.ToolTypeResponsesToolSearch, + ResponseToolSearchCall: &llm.ResponseToolSearchCall{ + CallID: callID, + Execution: tc.ResponseToolSearchCall.Execution, + Arguments: streamEvent.Delta, + }, + }, + }, + }, + }, + } + break + } + + tc.Function.Arguments += streamEvent.Delta resp.Choices = []llm.Choice{ { @@ -333,8 +390,21 @@ func (s *responsesOutboundStream) transformStreamChunk(event *httpclient.StreamE case StreamEventTypeFunctionCallArgumentsDone: // Function call completed - update state but don't emit an event - if streamEvent.CallID != "" { - if tc, ok := s.state.toolCalls[streamEvent.CallID]; ok { + callID := streamEvent.CallID + if callID == "" && streamEvent.ItemID != nil { + callID = s.state.itemToCallID[*streamEvent.ItemID] + if callID == "" { + callID = *streamEvent.ItemID + } + } + if callID != "" { + if tc, ok := s.state.toolCalls[callID]; ok { + if tc.ResponseToolSearchCall != nil { + if streamEvent.Arguments != "" { + tc.ResponseToolSearchCall.Arguments = streamEvent.Arguments + } + return nil + } if streamEvent.Name != "" { tc.Function.Name = streamEvent.Name } @@ -449,6 +519,17 @@ func (s *responsesOutboundStream) transformStreamChunk(event *httpclient.StreamE if streamEvent.Item == nil { return nil // Intentionally skip this event } + if streamEvent.Item.Type == "tool_search_call" { + if tc, ok := s.state.toolCalls[streamEvent.Item.CallID]; ok && tc.ResponseToolSearchCall != nil { + if streamEvent.Item.Execution != "" { + tc.ResponseToolSearchCall.Execution = streamEvent.Item.Execution + } + if streamEvent.Item.Arguments != "" { + tc.ResponseToolSearchCall.Arguments = streamEvent.Item.Arguments + } + } + return nil // Tool call was emitted by item.added and argument deltas. + } if streamEvent.Item.Type == "compaction" || streamEvent.Item.Type == "compaction_summary" { resp.Choices = []llm.Choice{{ Index: 0, diff --git a/llm/transformer/openai/responses/outbound_stream_test.go b/llm/transformer/openai/responses/outbound_stream_test.go index da708203d..799a50b82 100644 --- a/llm/transformer/openai/responses/outbound_stream_test.go +++ b/llm/transformer/openai/responses/outbound_stream_test.go @@ -696,3 +696,50 @@ func TestOutboundTransformer_TransformStream_PreservesPreviousResponseID(t *test require.Equal(t, "resp_prev_123", *actual[2].PreviousResponseID) require.Equal(t, llm.DoneResponse, actual[3]) } + +func TestOutboundTransformer_TransformStream_ToolSearchCallLifecycle(t *testing.T) { + callID := "call_search_123" + events := []*httpclient.StreamEvent{ + {Type: "response.created", Data: []byte(`{"type":"response.created","response":{"id":"resp_tool_search","object":"response","created_at":1700000000,"model":"gpt-5","status":"in_progress","output":[]}}`)}, + {Type: "response.output_item.added", Data: []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"ts_123","type":"tool_search_call","status":"in_progress","call_id":"call_search_123","execution":"client","arguments":""}}`)}, + {Type: "response.function_call_arguments.delta", Data: []byte(`{"type":"response.function_call_arguments.delta","item_id":"ts_123","output_index":0,"delta":"{\"query\":"}`)}, + {Type: "response.function_call_arguments.delta", Data: []byte(`{"type":"response.function_call_arguments.delta","item_id":"ts_123","output_index":0,"delta":"\"agents\"}"}`)}, + {Type: "response.function_call_arguments.done", Data: []byte(`{"type":"response.function_call_arguments.done","item_id":"ts_123","output_index":0}`)}, + {Type: "response.output_item.done", Data: []byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"ts_123","type":"tool_search_call","status":"completed","call_id":"call_search_123","execution":"client"}}`)}, + {Type: "response.completed", Data: []byte(`{"type":"response.completed","response":{"id":"resp_tool_search","object":"response","created_at":1700000000,"model":"gpt-5","status":"completed","output":[]}}`)}, + } + + stream := newResponsesOutboundStream(streams.AppendStream(streams.SliceStream(events), lo.ToPtr(llm.DoneStreamEvent))) + actual, err := streams.All(streams.NoNil(stream)) + require.NoError(t, err) + require.Len(t, actual, 6) + + added := actual[1].Choices[0].Delta.ToolCalls[0] + require.Equal(t, callID, added.ID) + require.Equal(t, llm.ToolTypeResponsesToolSearch, added.Type) + require.Equal(t, 0, added.Index) + require.NotNil(t, added.ResponseToolSearchCall) + require.Equal(t, callID, added.ResponseToolSearchCall.CallID) + require.Equal(t, "client", added.ResponseToolSearchCall.Execution) + + var arguments string + for _, response := range actual[2:4] { + require.Len(t, response.Choices, 1) + require.NotNil(t, response.Choices[0].Delta) + require.Len(t, response.Choices[0].Delta.ToolCalls, 1) + delta := response.Choices[0].Delta.ToolCalls[0] + require.Equal(t, llm.ToolTypeResponsesToolSearch, delta.Type) + require.Equal(t, 0, delta.Index) + require.NotNil(t, delta.ResponseToolSearchCall) + require.Equal(t, callID, delta.ResponseToolSearchCall.CallID) + require.Equal(t, "client", delta.ResponseToolSearchCall.Execution) + arguments += delta.ResponseToolSearchCall.Arguments + } + require.JSONEq(t, `{"query":"agents"}`, arguments) + require.NotNil(t, stream.state.toolCalls[callID]) + require.NotNil(t, stream.state.toolCalls[callID].ResponseToolSearchCall) + require.JSONEq(t, `{"query":"agents"}`, stream.state.toolCalls[callID].ResponseToolSearchCall.Arguments) + + require.Equal(t, "tool_calls", lo.FromPtr(actual[4].Choices[0].FinishReason)) + require.Equal(t, llm.DoneResponse, actual[5]) +} diff --git a/llm/transformer/openai/responses/outbound_test.go b/llm/transformer/openai/responses/outbound_test.go index de09e5393..c2f67a028 100644 --- a/llm/transformer/openai/responses/outbound_test.go +++ b/llm/transformer/openai/responses/outbound_test.go @@ -280,6 +280,34 @@ func TestOutboundTransformer_TransformRequest_ReplaysProviderRawToolsAndToolChoi require.Len(t, toolChoice["tools"], 1) } +func TestOutboundTransformer_TransformRequest_ReplaysFutureFunctionLikeTool(t *testing.T) { + inbound := NewInboundTransformer() + llmReq, err := inbound.TransformRequest(context.Background(), &httpclient.Request{Body: []byte(`{ + "model":"gpt-4o","input":"lookup","tools":[{ + "type":"future_client_tool","name":"lookup","execution":"client", + "future_option":{"mode":"fast"}, + "parameters":{"type":"object","properties":{"query":{"type":"string"}}} + }] + }`)}) + require.NoError(t, err) + require.Len(t, llmReq.Tools, 1) + require.Equal(t, llm.ToolTypeFunction, llmReq.Tools[0].Type) + + outbound, err := NewOutboundTransformer("https://api.openai.com", "test-api-key") + require.NoError(t, err) + httpReq, err := outbound.TransformRequest(context.Background(), llmReq) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(httpReq.Body, &payload)) + tools := payload["tools"].([]any) + require.Len(t, tools, 1) + tool := tools[0].(map[string]any) + require.Equal(t, "future_client_tool", tool["type"]) + require.Equal(t, "lookup", tool["name"]) + require.Equal(t, "fast", tool["future_option"].(map[string]any)["mode"]) +} + func TestOutboundTransformer_TransformRequest_ReplaysNamespaceTool(t *testing.T) { inbound := NewInboundTransformer() inboundReq := &httpclient.Request{ @@ -1265,6 +1293,40 @@ func TestOutboundTransformer_TransformResponse(t *testing.T) { require.Equal(t, int64(30), result.Usage.TotalTokens) }, }, + { + name: "response with tool search call", + httpResp: &httpclient.Response{ + StatusCode: http.StatusOK, + Body: []byte(`{ + "id":"resp_tool_search", + "object":"response", + "created_at":1759161016, + "status":"completed", + "model":"gpt-5", + "output":[{ + "id":"ts_123", + "type":"tool_search_call", + "status":"completed", + "call_id":"call_search_123", + "execution":"client", + "arguments":{"query":"agents"} + }] + }`), + }, + validate: func(t *testing.T, result *llm.Response) { + require.Len(t, result.Choices, 1) + require.Equal(t, "tool_calls", lo.FromPtr(result.Choices[0].FinishReason)) + require.NotNil(t, result.Choices[0].Message) + require.Len(t, result.Choices[0].Message.ToolCalls, 1) + tc := result.Choices[0].Message.ToolCalls[0] + require.Equal(t, "call_search_123", tc.ID) + require.Equal(t, llm.ToolTypeResponsesToolSearch, tc.Type) + require.NotNil(t, tc.ResponseToolSearchCall) + require.Equal(t, "call_search_123", tc.ResponseToolSearchCall.CallID) + require.Equal(t, "client", tc.ResponseToolSearchCall.Execution) + require.JSONEq(t, `{"query":"agents"}`, tc.ResponseToolSearchCall.Arguments) + }, + }, { name: "response with image generation result", httpResp: &httpclient.Response{ diff --git a/llm/transformer/openai/responses/request_extensions.go b/llm/transformer/openai/responses/request_extensions.go index a445b4843..1a285aaf9 100644 --- a/llm/transformer/openai/responses/request_extensions.go +++ b/llm/transformer/openai/responses/request_extensions.go @@ -3,6 +3,8 @@ package responses import ( "encoding/json" + "github.com/samber/lo" + "github.com/looplj/axonhub/llm" ) @@ -101,7 +103,7 @@ func buildRawOnlyToolFragments(tools []Tool, rawTools []json.RawMessage) []llm.O fragments := make([]llm.OpenAIResponsesRawFragment, 0, len(tools)) for i := range tools { - if i >= len(rawTools) || len(rawTools[i]) == 0 || isStructurallyRepresentedToolType(tools[i].Type) { + if i >= len(rawTools) || len(rawTools[i]) == 0 || (isStructurallyRepresentedToolType(tools[i].Type) && tools[i].Type != "tool_search") { continue } @@ -109,7 +111,7 @@ func buildRawOnlyToolFragments(tools []Tool, rawTools []json.RawMessage) []llm.O Type: tools[i].Type, Name: tools[i].Name, OriginalIndex: i, - RepresentedToolCount: representedNamespaceToolCount(tools[i]), + RepresentedToolCount: representedRawToolCount(tools[i]), Raw: cloneRaw(rawTools[i]), }) } @@ -117,7 +119,11 @@ func buildRawOnlyToolFragments(tools []Tool, rawTools []json.RawMessage) []llm.O return fragments } -func representedNamespaceToolCount(tool Tool) int { +// representedRawToolCount counts tool declarations represented by one preserved raw item. +func representedRawToolCount(tool Tool) int { + if tool.Type == "tool_search" { + return 1 + } if tool.Type != "namespace" { return 0 } @@ -134,7 +140,7 @@ func representedNamespaceToolCount(tool Tool) int { func isStructurallyRepresentedToolType(toolType string) bool { switch toolType { - case "function", "image_generation", "web_search", "custom": + case "function", "image_generation", "web_search", "custom", "tool_search": return true default: return false @@ -170,16 +176,18 @@ func buildRawOnlyInputFragments(input Input, rawItems []json.RawMessage) []llm.O fragments := make([]llm.OpenAIResponsesRawFragment, 0) for i := range input.Items { item := input.Items[i] - if i >= len(rawItems) || len(rawItems[i]) == 0 || isStructurallyRepresentedInputItem(item.Type) { + preserveRepresented := item.Type == "tool_search_call" || item.Type == "tool_search_output" + if i >= len(rawItems) || len(rawItems[i]) == 0 || (isStructurallyRepresentedInputItem(item.Type) && !preserveRepresented) { continue } fragments = append(fragments, llm.OpenAIResponsesRawFragment{ - Type: item.Type, - Name: item.Name, - CallID: item.CallID, - OriginalIndex: i, - Raw: cloneRaw(rawItems[i]), + Type: item.Type, + Name: item.Name, + CallID: item.CallID, + OriginalIndex: i, + RepresentedToolCount: lo.Ternary(preserveRepresented, 1, 0), + Raw: cloneRaw(rawItems[i]), }) } @@ -189,7 +197,8 @@ func buildRawOnlyInputFragments(input Input, rawItems []json.RawMessage) []llm.O func isStructurallyRepresentedInputItem(itemType string) bool { switch itemType { case "", "message", "input_text", "input_image", "function_call", "function_call_output", - "custom_tool_call", "custom_tool_call_output", "reasoning", "compaction", "compaction_summary": + "custom_tool_call", "custom_tool_call_output", "tool_search_call", "tool_search_output", + "reasoning", "compaction", "compaction_summary": return true default: return false @@ -256,20 +265,28 @@ func mergeRawOnlyInputItems(structuredRaw json.RawMessage, requestExt *llm.OpenA } } - total := len(structuredItems) + len(requestExt.RawInputItems) + representedCount := 0 + for _, fragment := range requestExt.RawInputItems { + representedCount += fragment.RepresentedToolCount + } + if representedCount > len(structuredItems) { + return nil, false + } + total := len(structuredItems) - representedCount + len(requestExt.RawInputItems) items := make([]json.RawMessage, 0, total) structuredIndex := 0 - rawByIndex := make(map[int]json.RawMessage, len(requestExt.RawInputItems)) + rawByIndex := make(map[int]llm.OpenAIResponsesRawFragment, len(requestExt.RawInputItems)) for _, fragment := range requestExt.RawInputItems { if len(fragment.Raw) == 0 || fragment.OriginalIndex < 0 { return nil, false } - rawByIndex[fragment.OriginalIndex] = cloneRaw(fragment.Raw) + rawByIndex[fragment.OriginalIndex] = fragment } for i := 0; i < total; i++ { - if raw, ok := rawByIndex[i]; ok { - items = append(items, raw) + if fragment, ok := rawByIndex[i]; ok { + items = append(items, cloneRaw(fragment.Raw)) + structuredIndex += fragment.RepresentedToolCount continue } if structuredIndex >= len(structuredItems) { diff --git a/llm/transformer/openai/responses/request_extensions_roundtrip_test.go b/llm/transformer/openai/responses/request_extensions_roundtrip_test.go new file mode 100644 index 000000000..109a5655c --- /dev/null +++ b/llm/transformer/openai/responses/request_extensions_roundtrip_test.go @@ -0,0 +1,62 @@ +package responses + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/looplj/axonhub/llm/httpclient" +) + +func TestRequestExtensions_ReplaysNamespaceWithFutureClientSubtool(t *testing.T) { + inbound := NewInboundTransformer() + llmRequest, err := inbound.TransformRequest(context.Background(), &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5", + "input":"use tools", + "tools":[{ + "type":"namespace", + "name":"workspace", + "tools":[ + {"type":"function","name":"read","parameters":{"type":"object"}}, + { + "type":"future_client_tool", + "name":"later", + "execution":"client", + "parameters":{"type":"object"}, + "future_option":{"mode":"lossless"} + } + ] + }] + }`)}) + require.NoError(t, err) + require.Len(t, llmRequest.Tools, 2) + + outbound, err := NewOutboundTransformer("https://api.openai.com", "test-api-key") + require.NoError(t, err) + httpRequest, err := outbound.TransformRequest(context.Background(), llmRequest) + require.NoError(t, err) + + var payload struct { + Tools []struct { + Type string `json:"type"` + Name string `json:"name"` + Tools []struct { + Type string `json:"type"` + Name string `json:"name"` + FutureOption map[string]any `json:"future_option"` + } `json:"tools"` + } `json:"tools"` + } + require.NoError(t, json.Unmarshal(httpRequest.Body, &payload)) + require.Len(t, payload.Tools, 1) + require.Equal(t, "namespace", payload.Tools[0].Type) + require.Equal(t, "workspace", payload.Tools[0].Name) + require.Len(t, payload.Tools[0].Tools, 2) + require.Equal(t, "function", payload.Tools[0].Tools[0].Type) + require.Equal(t, "read", payload.Tools[0].Tools[0].Name) + require.Equal(t, "future_client_tool", payload.Tools[0].Tools[1].Type) + require.Equal(t, "later", payload.Tools[0].Tools[1].Name) + require.Equal(t, "lossless", payload.Tools[0].Tools[1].FutureOption["mode"]) +} diff --git a/llm/transformer/openai/responses/testdata/tool-2.stream.jsonl b/llm/transformer/openai/responses/testdata/tool-2.stream.jsonl index 4c52cc313..357186afd 100644 --- a/llm/transformer/openai/responses/testdata/tool-2.stream.jsonl +++ b/llm/transformer/openai/responses/testdata/tool-2.stream.jsonl @@ -133,14 +133,14 @@ {"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 132, \"item_id\": \"fc_020592949fb9ce090069355e9ec7c8819698be65c589e9e388\", \"output_index\": 2, \"delta\": \" \", \"obfuscation\": \"KQcWZMfb84zVXuo\"}"} {"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 133, \"item_id\": \"fc_020592949fb9ce090069355e9ec7c8819698be65c589e9e388\", \"output_index\": 2, \"delta\": \"4\", \"obfuscation\": \"B6udkGzNwcN0usQ\"}"} {"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 134, \"item_id\": \"fc_020592949fb9ce090069355e9ec7c8819698be65c589e9e388\", \"output_index\": 2, \"delta\": \"\\\"}\", \"obfuscation\": \"TpnVvhWItbjFMt\"}"} -{"LastEventID": "", "Type": "response.function_call_arguments.done", "Data": "{\"type\": \"response.function_call_arguments.done\", \"sequence_number\": 135, \"item_id\": \"fc_020592949fb9ce090069355e9ec7c8819698be65c589e9e388\", \"output_index\": 2, \"arguments\": \"{\\\"expression\\\":\\\"25 * 4\\\"}\"}"} -{"LastEventID": "", "Type": "response.output_item.done", "Data": "{\"type\": \"response.output_item.done\", \"sequence_number\": 136, \"output_index\": 2, \"item\": {\"id\": \"fc_020592949fb9ce090069355e9ec7c8819698be65c589e9e388\", \"type\": \"function_call\", \"status\": \"completed\", \"arguments\": \"{\\\"expression\\\":\\\"25 * 4\\\"}\", \"call_id\": \"call_zfEfzIJcwVLuoHXhbdyXeb77\", \"name\": \"calculate\"}}"} -{"LastEventID": "", "Type": "response.output_item.added", "Data": "{\"type\": \"response.output_item.added\", \"sequence_number\": 137, \"output_index\": 3, \"item\": {\"id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"type\": \"function_call\", \"status\": \"in_progress\", \"arguments\": \"\", \"call_id\": \"call_QGIzrBsREQ0xDM1snIlUGG78\", \"name\": \"get_current_weather\"}}"} -{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 138, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"{\", \"obfuscation\": \"4I4Hgl6LAKpPHCj\"}"} -{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 139, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"\\\"location\", \"obfuscation\": \"ROsxksb\"}"} -{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 140, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"\\\":\", \"obfuscation\": \"2Dr3Om6WN96A4W\"}"} -{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 141, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"\\\"Tokyo\", \"obfuscation\": \"S9ppEddpLF\"}"} -{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 142, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"\\\"}\", \"obfuscation\": \"dNh9WMs3MgXwIy\"}"} +{"LastEventID": "", "Type": "response.output_item.added", "Data": "{\"type\": \"response.output_item.added\", \"sequence_number\": 135, \"output_index\": 3, \"item\": {\"id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"type\": \"function_call\", \"status\": \"in_progress\", \"arguments\": \"\", \"call_id\": \"call_QGIzrBsREQ0xDM1snIlUGG78\", \"name\": \"get_current_weather\"}}"} +{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 136, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"{\", \"obfuscation\": \"4I4Hgl6LAKpPHCj\"}"} +{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 137, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"\\\"location\", \"obfuscation\": \"ROsxksb\"}"} +{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 138, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"\\\":\", \"obfuscation\": \"2Dr3Om6WN96A4W\"}"} +{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 139, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"\\\"Tokyo\", \"obfuscation\": \"S9ppEddpLF\"}"} +{"LastEventID": "", "Type": "response.function_call_arguments.delta", "Data": "{\"type\": \"response.function_call_arguments.delta\", \"sequence_number\": 140, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"delta\": \"\\\"}\", \"obfuscation\": \"dNh9WMs3MgXwIy\"}"} +{"LastEventID": "", "Type": "response.function_call_arguments.done", "Data": "{\"type\": \"response.function_call_arguments.done\", \"sequence_number\": 141, \"item_id\": \"fc_020592949fb9ce090069355e9ec7c8819698be65c589e9e388\", \"output_index\": 2, \"arguments\": \"{\\\"expression\\\":\\\"25 * 4\\\"}\"}"} +{"LastEventID": "", "Type": "response.output_item.done", "Data": "{\"type\": \"response.output_item.done\", \"sequence_number\": 142, \"output_index\": 2, \"item\": {\"id\": \"fc_020592949fb9ce090069355e9ec7c8819698be65c589e9e388\", \"type\": \"function_call\", \"status\": \"completed\", \"arguments\": \"{\\\"expression\\\":\\\"25 * 4\\\"}\", \"call_id\": \"call_zfEfzIJcwVLuoHXhbdyXeb77\", \"name\": \"calculate\"}}"} {"LastEventID": "", "Type": "response.function_call_arguments.done", "Data": "{\"type\": \"response.function_call_arguments.done\", \"sequence_number\": 143, \"item_id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"output_index\": 3, \"arguments\": \"{\\\"location\\\":\\\"Tokyo\\\"}\"}"} {"LastEventID": "", "Type": "response.output_item.done", "Data": "{\"type\": \"response.output_item.done\", \"sequence_number\": 144, \"output_index\": 3, \"item\": {\"id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"type\": \"function_call\", \"status\": \"completed\", \"arguments\": \"{\\\"location\\\":\\\"Tokyo\\\"}\", \"call_id\": \"call_QGIzrBsREQ0xDM1snIlUGG78\", \"name\": \"get_current_weather\"}}"} {"LastEventID": "", "Type": "response.completed", "Data": "{\"type\": \"response.completed\", \"sequence_number\": 145, \"response\": {\"id\": \"resp_020592949fb9ce090069355e9a54788196911d78a6360a88f2\", \"object\": \"response\", \"created_at\": 1765105306, \"status\": \"completed\", \"background\": false, \"content_filters\": null, \"error\": null, \"incomplete_details\": null, \"instructions\": null, \"max_output_tokens\": null, \"max_tool_calls\": null, \"model\": \"gpt-4o\", \"output\": [{\"id\": \"msg_020592949fb9ce090069355e9c05408196bb3d459b4ca2b073\", \"type\": \"message\", \"status\": \"completed\", \"content\": [{\"type\": \"output_text\", \"annotations\": [], \"logprobs\": [], \"text\": \"Hello! I'm an AI assistant designed to help with a wide range of tasks, including answering math questions and providing current weather information. For your project, I'll first calculate the product of 25 and 4, and then I'll retrieve the current weather conditions in Tokyo.\\n\\nI'll be using dedicated tools for these tasks:\\n\\n1. A calculation tool to find the product of 25 multiplied by 4.\\n2. A weather information tool to get the latest weather update for Tokyo.\\n\\nLet's start with the calculations and weather retrieval. I'll execute both tasks simultaneously to save time.\"}], \"role\": \"assistant\"}, {\"id\": \"fc_020592949fb9ce090069355e9ec7c8819698be65c589e9e388\", \"type\": \"function_call\", \"status\": \"completed\", \"arguments\": \"{\\\"expression\\\":\\\"25 * 4\\\"}\", \"call_id\": \"call_zfEfzIJcwVLuoHXhbdyXeb77\", \"name\": \"calculate\"}, {\"id\": \"fc_020592949fb9ce090069355e9ef2308196ae83c4ec1c19243b\", \"type\": \"function_call\", \"status\": \"completed\", \"arguments\": \"{\\\"location\\\":\\\"Tokyo\\\"}\", \"call_id\": \"call_QGIzrBsREQ0xDM1snIlUGG78\", \"name\": \"get_current_weather\"}], \"parallel_tool_calls\": true, \"previous_response_id\": null, \"prompt_cache_key\": null, \"prompt_cache_retention\": null, \"reasoning\": {\"effort\": null, \"summary\": null}, \"safety_identifier\": null, \"service_tier\": \"default\", \"store\": true, \"temperature\": 1.0, \"text\": {\"format\": {\"type\": \"text\"}, \"verbosity\": \"medium\"}, \"tool_choice\": \"auto\", \"tools\": [{\"type\": \"function\", \"description\": \"Perform mathematical calculations\", \"name\": \"calculate\", \"parameters\": {\"properties\": {\"expression\": {\"type\": \"string\"}}, \"required\": [\"expression\"], \"type\": \"object\", \"additionalProperties\": false}, \"strict\": true}, {\"type\": \"function\", \"description\": \"Get the current weather for a specified location\", \"name\": \"get_current_weather\", \"parameters\": {\"properties\": {\"location\": {\"type\": \"string\"}}, \"required\": [\"location\"], \"type\": \"object\", \"additionalProperties\": false}, \"strict\": true}], \"top_logprobs\": 0, \"top_p\": 1.0, \"truncation\": \"disabled\", \"usage\": {\"input_tokens\": 8, \"input_tokens_details\": {\"cached_tokens\": 0}, \"output_tokens\": 162, \"output_tokens_details\": {\"reasoning_tokens\": 0}, \"total_tokens\": 170}, \"user\": null, \"metadata\": {}}}"} diff --git a/llm/transformer/openai/responses_chat_integration_test.go b/llm/transformer/openai/responses_chat_integration_test.go new file mode 100644 index 000000000..4e06f0b49 --- /dev/null +++ b/llm/transformer/openai/responses_chat_integration_test.go @@ -0,0 +1,1098 @@ +package openai + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/looplj/axonhub/llm" + "github.com/looplj/axonhub/llm/httpclient" + "github.com/looplj/axonhub/llm/streams" + "github.com/looplj/axonhub/llm/transformer" + responsesapi "github.com/looplj/axonhub/llm/transformer/openai/responses" +) + +func TestResponsesToChatTools_NonStreamingRoundTrip(t *testing.T) { + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5", + "input":[ + {"type":"additional_tools","role":"developer","tools":[ + {"type":"namespace","name":"collaboration","tools":[ + {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{}}} + ]} + ]}, + {"role":"user","type":"message","content":[{"type":"input_text","text":"continue"}]} + ], + "tools":[ + {"type":"custom","name":"apply_patch","description":"Apply patch"}, + {"type":"tool_search","execution":"client","description":"Find tools","parameters":{"type":"object","properties":{"query":{"type":"string"}}}}, + {"type":"future_client_tool","name":"future_lookup","execution":"client","parameters":{"type":"object","properties":{"id":{"type":"string"}}}} + ] + }`)}) + require.NoError(t, err) + + chatOutbound, err := NewOutboundTransformer("https://chat.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Tools, 4) + for _, tool := range converted.Tools { + require.Equal(t, "function", tool.Type) + } + + chatResponse := &httpclient.Response{ + StatusCode: http.StatusOK, + Request: chatRequest, + Body: []byte(`{ + "id":"chatcmpl_1","object":"chat.completion","created":1,"model":"glm-5.2", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","tool_calls":[ + {"id":"call_custom","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"patch\"}"}}, + {"id":"call_search","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"agents\"}"}}, + {"id":"call_future","type":"function","function":{"name":"future_lookup","arguments":"{\"id\":\"42\"}"}}, + {"id":"call_namespace","type":"function","function":{"name":"collaboration__spawn_agent","arguments":"{}"}} + ]}}], + "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2} + }`), + } + llmResponse, err := chatOutbound.TransformResponse(ctx, chatResponse) + require.NoError(t, err) + responsesResponse, err := responsesInbound.TransformResponse(ctx, llmResponse) + require.NoError(t, err) + + var result responsesapi.Response + require.NoError(t, json.Unmarshal(responsesResponse.Body, &result)) + require.Len(t, result.Output, 4) + require.Equal(t, "custom_tool_call", result.Output[0].Type) + require.Equal(t, "tool_search_call", result.Output[1].Type) + require.Equal(t, "function_call", result.Output[2].Type) + require.Equal(t, "future_lookup", result.Output[2].Name) + require.Equal(t, "function_call", result.Output[3].Type) + require.Equal(t, "spawn_agent", result.Output[3].Name) + require.Equal(t, "collaboration", result.Output[3].Namespace) +} + +func TestResponsesToChatTools_NormalizesMissingObjectParameterTypes(t *testing.T) { + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5", + "input":"run tools", + "tools":[ + {"type":"function","name":"plain"}, + {"type":"namespace","name":"agents","tools":[ + {"type":"function","name":"spawn","parameters":{"properties":{"task":{"type":"string"}}}} + ]}, + {"type":"tool_search","execution":"client","parameters":{}} + ] + }`)}) + require.NoError(t, err) + + chatOutbound, err := NewOutboundTransformer("https://chat.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Tools, 3) + for _, tool := range converted.Tools { + var schema map[string]any + require.NoError(t, json.Unmarshal(tool.Function.Parameters, &schema)) + require.Equal(t, "object", schema["type"]) + } + require.Equal(t, "plain", converted.Tools[0].Function.Name) + require.Equal(t, "agents__spawn", converted.Tools[1].Function.Name) + require.Equal(t, "tool_search", converted.Tools[2].Function.Name) +} + +func TestResponsesToChatHistory_MergesConsecutiveNamespaceCallsBeforeOutputs(t *testing.T) { + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5", + "input":[ + {"type":"additional_tools","role":"developer","tools":[ + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{}}}, + {"type":"function","name":"send_message","parameters":{"type":"object","properties":{}}} + ]} + ]}, + {"type":"function_call","call_id":"multi_agent_v1__spawn_agent:6","name":"spawn_agent","namespace":"multi_agent_v1","arguments":{}}, + {"type":"function_call","call_id":"multi_agent_v1__send_message:7","name":"send_message","namespace":"multi_agent_v1","arguments":{}}, + {"type":"function_call_output","call_id":"multi_agent_v1__spawn_agent:6","output":"spawned"}, + {"type":"function_call_output","call_id":"multi_agent_v1__send_message:7","output":"sent"}, + {"role":"user","type":"message","content":[{"type":"input_text","text":"continue"}]} + ] + }`)}) + require.NoError(t, err) + + chatOutbound, err := NewOutboundTransformer("https://chat.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Messages, 4) + require.Equal(t, "assistant", converted.Messages[0].Role) + require.Len(t, converted.Messages[0].ToolCalls, 2) + require.Equal(t, "multi_agent_v1__spawn_agent:6", converted.Messages[0].ToolCalls[0].ID) + require.Equal(t, "multi_agent_v1__spawn_agent", converted.Messages[0].ToolCalls[0].Function.Name) + require.Equal(t, "multi_agent_v1__send_message:7", converted.Messages[0].ToolCalls[1].ID) + require.Equal(t, "multi_agent_v1__send_message", converted.Messages[0].ToolCalls[1].Function.Name) + require.Equal(t, "tool", converted.Messages[1].Role) + require.Equal(t, "multi_agent_v1__spawn_agent:6", *converted.Messages[1].ToolCallID) + require.Equal(t, "tool", converted.Messages[2].Role) + require.Equal(t, "multi_agent_v1__send_message:7", *converted.Messages[2].ToolCallID) + require.Equal(t, "user", converted.Messages[3].Role) +} + +func TestResponsesToChatHistory_DropsResponsesOnlyEmptyAssistants(t *testing.T) { + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5", + "input":[ + {"role":"user","type":"message","content":[{"type":"input_text","text":"before"}]}, + {"id":"rs_empty","type":"reasoning","summary":[],"encrypted_content":"opaque"}, + {"id":"msg_empty","role":"assistant","type":"message","content":[]}, + {"role":"user","type":"message","content":[{"type":"input_text","text":"after"}]} + ] + }`)}) + require.NoError(t, err) + require.Len(t, llmRequest.Messages, 3) + + chatOutbound, err := NewOutboundTransformer("https://chat.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Messages, 2) + require.Equal(t, []string{"user", "user"}, []string{converted.Messages[0].Role, converted.Messages[1].Role}) + warnings, ok := chatRequest.TransformerMetadata[responsesChatToolWarningsMetadataKey].([]string) + require.True(t, ok) + require.Contains(t, warnings, "empty_assistant_message: dropped 1 history message(s) with no Chat-compatible payload") +} + +func TestResponsesToChatCustomTool_ParateraNonStreamingSimulation(t *testing.T) { + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5","input":"apply the patch","tools":[ + {"type":"function","name":"apply_patch","description":"JSON function with same name","parameters":{"type":"object"}}, + {"type":"custom","name":"apply_patch","description":"Apply unified patch. This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","syntax":"lark","definition":"start: /.+/"}}, + {"type":"custom","name":"grammar_without_syntax","format":{"type":"grammar","definition":"root: /.+/"}} + ] + }`)}) + require.NoError(t, err) + + chatOutbound, err := NewOutboundTransformer("https://paratera.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Tools, 3) + customChatTool := converted.Tools[1] + require.Equal(t, "function", customChatTool.Type) + require.Equal(t, "axonhub_custom_tool_1", customChatTool.Function.Name) + require.Contains(t, customChatTool.Function.Description, "represented as a Chat Completions function") + require.Contains(t, customChatTool.Function.Description, "The outer function arguments must be valid JSON") + require.Contains(t, customChatTool.Function.Description, "Original custom-tool instructions (apply to the `input` string):") + require.Contains(t, customChatTool.Function.Description, "This is a FREEFORM tool, so do not wrap the patch in JSON.") + require.Contains(t, customChatTool.Function.Description, "Chat Completions cannot enforce the original custom-tool grammar during sampling") + require.Contains(t, customChatTool.Function.Description, "Treat this grammar as required guidance for the `input` string:\nlark grammar:\nstart: /.+/") + var customSchema struct { + Type string `json:"type"` + Properties map[string]struct { + Type string `json:"type"` + Description string `json:"description"` + } `json:"properties"` + Required []string `json:"required"` + AdditionalProperties bool `json:"additionalProperties"` + } + require.NoError(t, json.Unmarshal(customChatTool.Function.Parameters, &customSchema)) + require.Equal(t, "object", customSchema.Type) + require.Equal(t, []string{"input"}, customSchema.Required) + require.False(t, customSchema.AdditionalProperties) + require.Equal(t, "string", customSchema.Properties["input"].Type) + require.Contains(t, customSchema.Properties["input"].Description, "Exact raw custom-tool input") + require.Contains(t, customSchema.Properties["input"].Description, "Escape quotes, backslashes") + require.Contains(t, customSchema.Properties["input"].Description, "outer JSON string") + + syntaxlessTool := converted.Tools[2] + require.Equal(t, "grammar_without_syntax", syntaxlessTool.Function.Name) + require.Contains(t, syntaxlessTool.Function.Description, "Treat this grammar as required guidance for the `input` string:\ngrammar:\nroot: /.+/") + require.NotContains(t, syntaxlessTool.Function.Description, "this grammar") + + mappings := responsesChatToolMappings(chatRequest) + mapping, ok := mappings[customChatTool.Function.Name] + require.True(t, ok) + require.Equal(t, responsesChatToolCustom, mapping.Kind) + require.Equal(t, "apply_patch", mapping.Name) + require.False(t, mapping.HistoryOnly) + + responseBody := marshalResponsesChatTestJSON(t, map[string]any{ + "id": "chatcmpl_custom", "object": "chat.completion", "created": 1, "model": "glm-5.2", + "choices": []any{map[string]any{ + "index": 0, "finish_reason": "tool_calls", + "message": map[string]any{ + "role": "assistant", "reasoning_content": "Need to apply the requested patch.", + "tool_calls": []any{map[string]any{ + "id": "call_patch_42", "type": "function", + "function": map[string]any{"name": customChatTool.Function.Name, "arguments": `{"input":"*** Begin Patch"}`}, + }}, + }, + }}, + }) + llmResponse, err := chatOutbound.TransformResponse(ctx, &httpclient.Response{ + StatusCode: http.StatusOK, Request: chatRequest, Body: responseBody, + }) + require.NoError(t, err) + require.Equal(t, "tool_calls", *llmResponse.Choices[0].FinishReason) + require.Equal(t, "Need to apply the requested patch.", *llmResponse.Choices[0].Message.ReasoningContent) + require.Len(t, llmResponse.Choices[0].Message.ToolCalls, 1) + customCall := llmResponse.Choices[0].Message.ToolCalls[0] + require.Equal(t, "call_patch_42", customCall.ID) + require.NotNil(t, customCall.ResponseCustomToolCall) + require.Equal(t, "call_patch_42", customCall.ResponseCustomToolCall.CallID) + require.Equal(t, "apply_patch", customCall.ResponseCustomToolCall.Name) + require.Equal(t, "*** Begin Patch", customCall.ResponseCustomToolCall.Input) + + responsesResponse, err := responsesInbound.TransformResponse(ctx, llmResponse) + require.NoError(t, err) + var result responsesapi.Response + require.NoError(t, json.Unmarshal(responsesResponse.Body, &result)) + var reasoningItem, customItem *responsesapi.Item + for i := range result.Output { + switch result.Output[i].Type { + case "reasoning": + reasoningItem = &result.Output[i] + case "custom_tool_call": + customItem = &result.Output[i] + } + } + require.NotNil(t, reasoningItem) + require.NotNil(t, customItem) + require.Equal(t, "call_patch_42", customItem.CallID) + require.Equal(t, "apply_patch", customItem.Name) + require.Equal(t, "*** Begin Patch", *customItem.Input) +} + +func TestResponsesToChatCustomTool_ParateraStreamingSimulation(t *testing.T) { + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5","stream":true,"input":"apply the patch","tools":[ + {"type":"function","name":"apply_patch","parameters":{"type":"object"}}, + {"type":"custom","name":"apply_patch","description":"Apply unified patch"} + ] + }`)}) + require.NoError(t, err) + + chatOutbound, err := NewOutboundTransformer("https://paratera.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Tools, 2) + customChatName := converted.Tools[1].Function.Name + require.Equal(t, "axonhub_custom_tool_1", customChatName) + require.Contains(t, responsesChatToolMappings(chatRequest), customChatName) + + chatChunk := func(choice map[string]any) *httpclient.StreamEvent { + return &httpclient.StreamEvent{Data: marshalResponsesChatTestJSON(t, map[string]any{ + "id": "chatcmpl_stream", "object": "chat.completion.chunk", "created": 1, "model": "glm-5.2", + "choices": []any{choice}, + })} + } + providerEvents := []*httpclient.StreamEvent{ + chatChunk(map[string]any{ + "index": 0, "delta": map[string]any{"role": "assistant", "reasoning_content": "Need a patch."}, + }), + chatChunk(map[string]any{ + "index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{ + "index": 0, "id": "call_patch_stream", "type": "function", + "function": map[string]any{"name": customChatName, "arguments": `{"input":"*** Begin`}, + }}}, + }), + chatChunk(map[string]any{ + "index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{ + "index": 0, "function": map[string]any{"arguments": ` Patch"}`}, + }}}, + }), + chatChunk(map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "tool_calls"}), + {Data: []byte("[DONE]")}, + } + llmStream, err := chatOutbound.TransformStream(ctx, chatRequest, streams.SliceStream(providerEvents)) + require.NoError(t, err) + responsesStream, err := responsesInbound.TransformStream(ctx, llmStream) + require.NoError(t, err) + + var events []responsesapi.StreamEvent + for responsesStream.Next() { + var event responsesapi.StreamEvent + require.NoError(t, json.Unmarshal(responsesStream.Current().Data, &event)) + events = append(events, event) + } + require.NoError(t, responsesStream.Err()) + + customLifecycle := make([]responsesapi.StreamEventType, 0, 4) + var customDone *responsesapi.Item + var reasoningDone bool + var completed *responsesapi.Response + for i := range events { + event := &events[i] + switch event.Type { + case responsesapi.StreamEventTypeOutputItemAdded: + if event.Item != nil && event.Item.Type == "custom_tool_call" { + customLifecycle = append(customLifecycle, event.Type) + require.Equal(t, "call_patch_stream", event.Item.CallID) + require.Equal(t, "apply_patch", event.Item.Name) + } + case responsesapi.StreamEventTypeCustomToolCallInputDelta: + customLifecycle = append(customLifecycle, event.Type) + require.Equal(t, "*** Begin Patch", event.Delta) + require.NotContains(t, event.Delta, `{"input"`) + case responsesapi.StreamEventTypeCustomToolCallInputDone: + customLifecycle = append(customLifecycle, event.Type) + require.Equal(t, "*** Begin Patch", event.Input) + case responsesapi.StreamEventTypeOutputItemDone: + if event.Item != nil && event.Item.Type == "reasoning" { + reasoningDone = true + } + if event.Item != nil && event.Item.Type == "custom_tool_call" { + customLifecycle = append(customLifecycle, event.Type) + customDone = event.Item + } + case responsesapi.StreamEventTypeResponseCompleted: + completed = event.Response + } + } + require.True(t, reasoningDone) + require.Equal(t, []responsesapi.StreamEventType{ + responsesapi.StreamEventTypeOutputItemAdded, + responsesapi.StreamEventTypeCustomToolCallInputDelta, + responsesapi.StreamEventTypeCustomToolCallInputDone, + responsesapi.StreamEventTypeOutputItemDone, + }, customLifecycle) + require.NotNil(t, customDone) + require.Equal(t, "call_patch_stream", customDone.CallID) + require.Equal(t, "apply_patch", customDone.Name) + require.Equal(t, "*** Begin Patch", *customDone.Input) + require.NotNil(t, completed) + require.Equal(t, "completed", *completed.Status) +} + +func TestResponsesToChatCustomTool_StreamingSingleChunkWrapper(t *testing.T) { + wrapper := string(marshalResponsesChatTestJSON(t, map[string]string{"input": "*** Begin Patch"})) + events, streamErr := simulateResponsesChatCustomStream(t, []string{wrapper}) + require.NoError(t, streamErr) + require.Equal(t, "*** Begin Patch", completedCustomInput(t, events)) +} + +func TestResponsesToChatCustomTool_StreamingRawArgumentsWithoutWrapper(t *testing.T) { + events, streamErr := simulateResponsesChatCustomStream(t, []string{"*** Begin Patch"}) + require.NoError(t, streamErr) + require.Equal(t, "*** Begin Patch", completedCustomInput(t, events)) +} + +func TestResponsesToChatStream_MapsAbnormalFinishReasons(t *testing.T) { + tests := []struct { + finishReason string + terminalType responsesapi.StreamEventType + status string + incompleteReason string + }{ + {finishReason: "length", terminalType: responsesapi.StreamEventTypeResponseIncomplete, status: "incomplete", incompleteReason: "max_output_tokens"}, + {finishReason: "content_filter", terminalType: responsesapi.StreamEventTypeResponseIncomplete, status: "incomplete", incompleteReason: "content_filter"}, + {finishReason: "error", terminalType: responsesapi.StreamEventTypeResponseFailed, status: "failed"}, + } + for _, tt := range tests { + t.Run(tt.finishReason, func(t *testing.T) { + events, streamErr := simulateResponsesChatStream(t, `{"model":"gpt-5.5","stream":true,"input":"hello"}`, []map[string]any{ + {"index": 0, "delta": map[string]any{"role": "assistant", "content": "partial"}}, + {"index": 0, "delta": map[string]any{}, "finish_reason": tt.finishReason}, + }) + require.NoError(t, streamErr) + require.NotEmpty(t, events) + terminal := events[len(events)-1] + require.Equal(t, tt.terminalType, terminal.Type) + require.NotNil(t, terminal.Response) + require.Equal(t, tt.status, *terminal.Response.Status) + if tt.incompleteReason != "" { + require.NotNil(t, terminal.Response.IncompleteDetails) + require.Equal(t, tt.incompleteReason, terminal.Response.IncompleteDetails.Reason) + } + }) + } +} + +func TestResponsesToChatCustomTool_StreamAcceptsLateOrFragmentedName(t *testing.T) { + wrapper := string(marshalResponsesChatTestJSON(t, map[string]string{"input": "*** Begin Patch"})) + tests := []struct { + name string + build func(string) []map[string]any + }{ + { + name: "id before name", + build: func(customName string) []map[string]any { + return []map[string]any{ + responsesChatToolDelta(map[string]any{ + "index": 0, "id": "call_patch_late_name", "type": "function", "function": map[string]any{}, + }), + responsesChatToolDelta(map[string]any{ + "index": 0, "function": map[string]any{"name": customName, "arguments": wrapper}, + }), + } + }, + }, + { + name: "fragmented name", + build: func(customName string) []map[string]any { + split := len(customName) / 2 + return []map[string]any{ + responsesChatToolDelta(map[string]any{ + "index": 0, "id": "call_patch_fragmented_name", "type": "function", + "function": map[string]any{"name": customName[:split]}, + }), + responsesChatToolDelta(map[string]any{ + "index": 0, "function": map[string]any{"name": customName[split:], "arguments": wrapper}, + }), + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var events []responsesapi.StreamEvent + var streamErr error + require.NotPanics(t, func() { + events, streamErr = simulateResponsesChatCustomChoices(t, tt.build) + }) + require.NoError(t, streamErr) + require.Equal(t, "*** Begin Patch", completedCustomInput(t, events)) + }) + } +} + +func TestResponsesToChatCustomTool_StreamKeepsItemIDWhenCallIDArrivesLate(t *testing.T) { + wrapper := string(marshalResponsesChatTestJSON(t, map[string]string{"input": "*** Begin Patch"})) + events, streamErr := simulateResponsesChatCustomChoices(t, func(customName string) []map[string]any { + return []map[string]any{ + responsesChatToolDelta(map[string]any{ + "index": 0, "type": "function", "function": map[string]any{"name": customName}, + }), + responsesChatToolDelta(map[string]any{ + "index": 0, "id": "call_patch_late_id", "function": map[string]any{"arguments": wrapper}, + }), + } + }) + require.NoError(t, streamErr) + + var added, done *responsesapi.Item + for i := range events { + event := events[i] + if event.Item == nil || event.Item.Type != "custom_tool_call" { + continue + } + switch event.Type { + case responsesapi.StreamEventTypeOutputItemAdded: + added = event.Item + case responsesapi.StreamEventTypeOutputItemDone: + done = event.Item + } + } + require.NotNil(t, added) + require.NotNil(t, done) + require.Equal(t, added.ID, done.ID) + require.Equal(t, "call_patch_late_id", added.CallID) + require.Equal(t, "call_patch_late_id", done.CallID) + require.Equal(t, added.CallID, done.CallID) + require.Equal(t, "*** Begin Patch", *done.Input) +} + +func TestResponsesToChatStream_ReassemblesFragmentedPlainFunctionName(t *testing.T) { + events, streamErr := simulateResponsesChatStream(t, `{ + "model":"gpt-5.5","stream":true,"input":"look up the city","tools":[ + {"type":"function","name":"lookup","parameters":{"type":"object"}}, + {"type":"custom","name":"apply_patch"} + ] + }`, []map[string]any{ + responsesChatToolDelta(map[string]any{ + "index": 0, "id": "call_lookup", "type": "function", + "function": map[string]any{"name": "look", "arguments": `{"city":"Par`}, + }), + responsesChatToolDelta(map[string]any{ + "index": 0, "function": map[string]any{"name": "up", "arguments": `is"}`}, + }), + {"index": 0, "delta": map[string]any{}, "finish_reason": "tool_calls"}, + }) + require.NoError(t, streamErr) + + var done *responsesapi.Item + for i := range events { + event := events[i] + if event.Type == responsesapi.StreamEventTypeOutputItemDone && event.Item != nil && event.Item.Type == "function_call" { + done = event.Item + } + } + require.NotNil(t, done) + require.Equal(t, "call_lookup", done.CallID) + require.Equal(t, "lookup", done.Name) + require.JSONEq(t, `{"city":"Paris"}`, done.Arguments) +} + +func TestResponsesToChatStream_ResolvesCatalogPrefixAmbiguityAtFinish(t *testing.T) { + tests := []struct { + name string + toolDeltas []map[string]any + wantName string + wantArgs string + }{ + { + name: "fragmented long name", + toolDeltas: []map[string]any{ + responsesChatToolDelta(map[string]any{ + "index": 0, "id": "call_get_weather", "type": "function", + "function": map[string]any{"name": "get", "arguments": `{"city":"Par`}, + }), + responsesChatToolDelta(map[string]any{ + "index": 0, "function": map[string]any{"name": "_weather", "arguments": `is"}`}, + }), + }, + wantName: "get_weather", + wantArgs: `{"city":"Paris"}`, + }, + { + name: "complete short name", + toolDeltas: []map[string]any{ + responsesChatToolDelta(map[string]any{ + "index": 0, "id": "call_get", "type": "function", + "function": map[string]any{"name": "get", "arguments": `{"id":"42"}`}, + }), + }, + wantName: "get", + wantArgs: `{"id":"42"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + choices := append([]map[string]any{}, tt.toolDeltas...) + choices = append(choices, map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "tool_calls"}) + events, streamErr := simulateResponsesChatStream(t, `{ + "model":"gpt-5.5","stream":true,"input":"fetch data","tools":[ + {"type":"function","name":"get","parameters":{"type":"object"}}, + {"type":"function","name":"get_weather","parameters":{"type":"object"}} + ] + }`, choices) + require.NoError(t, streamErr) + + var doneItems []*responsesapi.Item + for i := range events { + event := events[i] + if event.Type == responsesapi.StreamEventTypeOutputItemDone && event.Item != nil && event.Item.Type == "function_call" { + doneItems = append(doneItems, event.Item) + } + } + require.Len(t, doneItems, 1) + require.Equal(t, tt.wantName, doneItems[0].Name) + require.JSONEq(t, tt.wantArgs, doneItems[0].Arguments) + }) + } +} + +func TestResponsesToChatCustomTool_StreamDoesNotMatchNameSuffix(t *testing.T) { + events, streamErr := simulateResponsesChatStream(t, `{ + "model":"gpt-5.5","stream":true,"input":"apply the patch","tools":[ + {"type":"custom","name":"apply_patch"}, + {"type":"custom","name":"patch"} + ] + }`, []map[string]any{ + responsesChatToolDelta(map[string]any{ + "index": 0, "id": "call_apply_patch", "type": "function", + "function": map[string]any{"name": "apply_", "arguments": `{"input":"*** Begin`}, + }), + responsesChatToolDelta(map[string]any{ + "index": 0, "function": map[string]any{"name": "patch", "arguments": ` Patch"}`}, + }), + {"index": 0, "delta": map[string]any{}, "finish_reason": "tool_calls"}, + }) + require.NoError(t, streamErr) + + var done *responsesapi.Item + for i := range events { + event := events[i] + if event.Type == responsesapi.StreamEventTypeOutputItemDone && event.Item != nil && event.Item.Type == "custom_tool_call" { + done = event.Item + } + } + require.NotNil(t, done) + require.Equal(t, "call_apply_patch", done.CallID) + require.Equal(t, "apply_patch", done.Name) + require.Equal(t, "*** Begin Patch", *done.Input) +} + +func TestResponsesToChatStream_ParallelPlainAndCustomCallsCloseAfterAllDeltas(t *testing.T) { + events, streamErr := simulateResponsesChatStream(t, `{ + "model":"gpt-5.5","stream":true,"input":"look up and patch","tools":[ + {"type":"function","name":"lookup","parameters":{"type":"object"}}, + {"type":"custom","name":"apply_patch"} + ] + }`, []map[string]any{ + responsesChatToolDelta(map[string]any{ + "index": 0, "id": "call_lookup_parallel", "type": "function", + "function": map[string]any{"name": "lookup", "arguments": `{"query":"hel`}, + }), + responsesChatToolDelta(map[string]any{ + "index": 1, "id": "call_patch_parallel", "type": "function", + "function": map[string]any{"name": "apply_patch", "arguments": `{"input":"pat`}, + }), + responsesChatToolDelta(map[string]any{ + "index": 0, "function": map[string]any{"arguments": `lo"}`}, + }), + responsesChatToolDelta(map[string]any{ + "index": 1, "function": map[string]any{"arguments": `ch"}`}, + }), + {"index": 0, "delta": map[string]any{}, "finish_reason": "tool_calls"}, + }) + require.NoError(t, streamErr) + + lastLookupDeltaIndex := -1 + lookupArgumentsDoneIndex := -1 + lookupOutputDoneIndex := -1 + lookupArgumentsDoneCount := 0 + lookupOutputDoneCount := 0 + customOutputDoneCount := 0 + for i := range events { + event := events[i] + if event.Type == responsesapi.StreamEventTypeFunctionCallArgumentsDelta && event.ItemID != nil && *event.ItemID == "call_lookup_parallel" { + lastLookupDeltaIndex = i + } + if event.Type == responsesapi.StreamEventTypeFunctionCallArgumentsDone && event.ItemID != nil && *event.ItemID == "call_lookup_parallel" { + lookupArgumentsDoneCount++ + lookupArgumentsDoneIndex = i + require.JSONEq(t, `{"query":"hello"}`, event.Arguments) + } + if event.Type != responsesapi.StreamEventTypeOutputItemDone || event.Item == nil { + continue + } + switch event.Item.CallID { + case "call_lookup_parallel": + lookupOutputDoneCount++ + lookupOutputDoneIndex = i + require.Equal(t, "function_call", event.Item.Type) + require.JSONEq(t, `{"query":"hello"}`, event.Item.Arguments) + case "call_patch_parallel": + customOutputDoneCount++ + require.Equal(t, "custom_tool_call", event.Item.Type) + require.Equal(t, "patch", *event.Item.Input) + } + } + require.NotEqual(t, -1, lastLookupDeltaIndex) + require.Greater(t, lookupArgumentsDoneIndex, lastLookupDeltaIndex) + require.Greater(t, lookupOutputDoneIndex, lookupArgumentsDoneIndex) + require.Equal(t, 1, lookupArgumentsDoneCount) + require.Equal(t, 1, lookupOutputDoneCount) + require.Equal(t, 1, customOutputDoneCount) +} + +func TestResponsesToChatCustomTool_PreservesJSONLikeRawInput(t *testing.T) { + rawInput := `{"path":"C:\\tmp","quote":"say \"hi\""}` + wrapper := string(marshalResponsesChatTestJSON(t, map[string]string{"input": rawInput})) + + t.Run("non-streaming", func(t *testing.T) { + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5","input":"apply the patch","tools":[ + {"type":"custom","name":"apply_patch","description":"Apply unified patch"} + ] + }`)}) + require.NoError(t, err) + chatOutbound, err := NewOutboundTransformer("https://paratera.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Tools, 1) + responseBody := marshalResponsesChatTestJSON(t, map[string]any{ + "id": "chatcmpl_json_input", "object": "chat.completion", "created": 1, "model": "glm-5.2", + "choices": []any{map[string]any{ + "index": 0, "finish_reason": "tool_calls", + "message": map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{ + "id": "call_json_input", "type": "function", + "function": map[string]any{"name": converted.Tools[0].Function.Name, "arguments": wrapper}, + }}}, + }}, + }) + llmResponse, err := chatOutbound.TransformResponse(ctx, &httpclient.Response{ + StatusCode: http.StatusOK, Request: chatRequest, Body: responseBody, + }) + require.NoError(t, err) + require.Equal(t, rawInput, llmResponse.Choices[0].Message.ToolCalls[0].ResponseCustomToolCall.Input) + + responsesResponse, err := responsesInbound.TransformResponse(ctx, llmResponse) + require.NoError(t, err) + var result responsesapi.Response + require.NoError(t, json.Unmarshal(responsesResponse.Body, &result)) + require.Len(t, result.Output, 1) + require.Equal(t, "custom_tool_call", result.Output[0].Type) + require.Equal(t, rawInput, *result.Output[0].Input) + }) + + t.Run("streaming", func(t *testing.T) { + split := len(wrapper) / 2 + events, streamErr := simulateResponsesChatCustomStream(t, []string{wrapper[:split], wrapper[split:]}) + require.NoError(t, streamErr) + require.Equal(t, rawInput, completedCustomInput(t, events)) + }) +} + +func TestResponsesToChatCustomTool_UnwrapFallbackPreservesRawInput(t *testing.T) { + tests := []struct { + name string + arguments string + want string + }{ + {name: "wrapped input", arguments: `{"input":"patch"}`, want: "patch"}, + {name: "wrapped empty input", arguments: `{"input":""}`, want: ""}, + {name: "missing input", arguments: `{}`, want: `{}`}, + {name: "other field", arguments: `{"patch":"value"}`, want: `{"patch":"value"}`}, + {name: "null input", arguments: `{"input":null}`, want: `{"input":null}`}, + {name: "malformed wrapper", arguments: `{"input":"patch"`, want: `{"input":"patch"`}, + } + + mappings := map[string]responsesChatToolMapping{ + "apply_patch": {Kind: responsesChatToolCustom, Name: "apply_patch"}, + } + for _, tt := range tests { + t.Run(tt.name+" non-streaming", func(t *testing.T) { + message := &llm.Message{ToolCalls: []llm.ToolCall{{ + ID: "call_patch", Function: llm.FunctionCall{Name: "apply_patch", Arguments: tt.arguments}, + }}} + restoreResponsesChatMessage(message, mappings, true) + require.Len(t, message.ToolCalls, 1) + require.NotNil(t, message.ToolCalls[0].ResponseCustomToolCall) + require.Equal(t, tt.want, message.ToolCalls[0].ResponseCustomToolCall.Input) + }) + + t.Run(tt.name+" streaming", func(t *testing.T) { + events, streamErr := simulateResponsesChatCustomStream(t, []string{tt.arguments}) + require.NoError(t, streamErr) + require.Equal(t, tt.want, completedCustomInput(t, events)) + }) + } +} + +func TestResponsesToChatCustomTool_FormatDegradationWarning(t *testing.T) { + tests := []struct { + name string + tool string + wantWarning bool + }{ + { + name: "grammar format", + tool: `{"type":"custom","name":"apply_patch","format":{"type":"grammar","syntax":"lark","definition":"start: /.+/"}}`, + wantWarning: true, + }, + { + name: "no format", + tool: `{"type":"custom","name":"apply_patch"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + responsesInbound := responsesapi.NewInboundTransformer() + body := []byte(`{"model":"gpt-5.5","input":"apply the patch","tools":[` + tt.tool + `]}`) + llmRequest, err := responsesInbound.TransformRequest(t.Context(), &httpclient.Request{Body: body}) + require.NoError(t, err) + chatOutbound, err := NewOutboundTransformer("https://paratera.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(t.Context(), llmRequest) + require.NoError(t, err) + + warnings, warned := chatRequest.TransformerMetadata[responsesChatToolWarningsMetadataKey].([]string) + if !tt.wantWarning { + require.False(t, warned) + return + } + require.True(t, warned) + require.NotEmpty(t, warnings) + require.Condition(t, func() bool { + for _, warning := range warnings { + if strings.Contains(warning, "format") || strings.Contains(warning, "grammar") { + return true + } + } + return false + }, "expected a format degradation warning, got %v", warnings) + }) + } +} + +func responsesChatToolDelta(toolCall map[string]any) map[string]any { + return map[string]any{ + "index": 0, + "delta": map[string]any{"tool_calls": []any{toolCall}}, + } +} + +func simulateResponsesChatStream(t *testing.T, requestBody string, choices []map[string]any) ([]responsesapi.StreamEvent, error) { + t.Helper() + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(requestBody)}) + require.NoError(t, err) + chatOutbound, err := NewOutboundTransformer("https://paratera.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + + providerEvents := make([]*httpclient.StreamEvent, 0, len(choices)+1) + for _, choice := range choices { + providerEvents = append(providerEvents, &httpclient.StreamEvent{Data: marshalResponsesChatTestJSON(t, map[string]any{ + "id": "chatcmpl_stream_abnormal", "object": "chat.completion.chunk", "created": 1, "model": "glm-5.2", + "choices": []any{choice}, + })}) + } + providerEvents = append(providerEvents, &httpclient.StreamEvent{Data: []byte("[DONE]")}) + llmStream, err := chatOutbound.TransformStream(ctx, chatRequest, streams.SliceStream(providerEvents)) + require.NoError(t, err) + responsesStream, err := responsesInbound.TransformStream(ctx, llmStream) + require.NoError(t, err) + + var events []responsesapi.StreamEvent + for responsesStream.Next() { + var event responsesapi.StreamEvent + require.NoError(t, json.Unmarshal(responsesStream.Current().Data, &event)) + events = append(events, event) + } + return events, responsesStream.Err() +} + +func simulateResponsesChatCustomChoices( + t *testing.T, + build func(customName string) []map[string]any, +) ([]responsesapi.StreamEvent, error) { + t.Helper() + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5","stream":true,"input":"apply the patch","tools":[ + {"type":"custom","name":"apply_patch","description":"Apply unified patch"} + ] + }`)}) + require.NoError(t, err) + chatOutbound, err := NewOutboundTransformer("https://paratera.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Tools, 1) + + choices := build(converted.Tools[0].Function.Name) + choices = append(choices, map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "tool_calls"}) + providerEvents := make([]*httpclient.StreamEvent, 0, len(choices)+1) + for _, choice := range choices { + providerEvents = append(providerEvents, &httpclient.StreamEvent{Data: marshalResponsesChatTestJSON(t, map[string]any{ + "id": "chatcmpl_stream_custom_abnormal", "object": "chat.completion.chunk", "created": 1, "model": "glm-5.2", + "choices": []any{choice}, + })}) + } + providerEvents = append(providerEvents, &httpclient.StreamEvent{Data: []byte("[DONE]")}) + llmStream, err := chatOutbound.TransformStream(ctx, chatRequest, streams.SliceStream(providerEvents)) + require.NoError(t, err) + responsesStream, err := responsesInbound.TransformStream(ctx, llmStream) + require.NoError(t, err) + + var events []responsesapi.StreamEvent + for responsesStream.Next() { + var event responsesapi.StreamEvent + require.NoError(t, json.Unmarshal(responsesStream.Current().Data, &event)) + events = append(events, event) + } + return events, responsesStream.Err() +} + +func simulateResponsesChatCustomStream(t *testing.T, argumentFragments []string) ([]responsesapi.StreamEvent, error) { + t.Helper() + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5","stream":true,"input":"apply the patch","tools":[ + {"type":"custom","name":"apply_patch","description":"Apply unified patch"} + ] + }`)}) + require.NoError(t, err) + chatOutbound, err := NewOutboundTransformer("https://paratera.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Tools, 1) + + chatChunk := func(choice map[string]any) *httpclient.StreamEvent { + return &httpclient.StreamEvent{Data: marshalResponsesChatTestJSON(t, map[string]any{ + "id": "chatcmpl_stream_edge", "object": "chat.completion.chunk", "created": 1, "model": "glm-5.2", + "choices": []any{choice}, + })} + } + providerEvents := make([]*httpclient.StreamEvent, 0, len(argumentFragments)+2) + for i, fragment := range argumentFragments { + toolCall := map[string]any{"index": 0, "function": map[string]any{"arguments": fragment}} + if i == 0 { + toolCall["id"] = "call_patch_edge" + toolCall["type"] = "function" + toolCall["function"].(map[string]any)["name"] = converted.Tools[0].Function.Name + } + providerEvents = append(providerEvents, chatChunk(map[string]any{ + "index": 0, "delta": map[string]any{"tool_calls": []any{toolCall}}, + })) + } + providerEvents = append(providerEvents, + chatChunk(map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "tool_calls"}), + &httpclient.StreamEvent{Data: []byte("[DONE]")}, + ) + llmStream, err := chatOutbound.TransformStream(ctx, chatRequest, streams.SliceStream(providerEvents)) + require.NoError(t, err) + responsesStream, err := responsesInbound.TransformStream(ctx, llmStream) + require.NoError(t, err) + + var events []responsesapi.StreamEvent + for responsesStream.Next() { + var event responsesapi.StreamEvent + require.NoError(t, json.Unmarshal(responsesStream.Current().Data, &event)) + events = append(events, event) + } + return events, responsesStream.Err() +} + +func completedCustomInput(t *testing.T, events []responsesapi.StreamEvent) string { + t.Helper() + for i := range events { + event := events[i] + if event.Type == responsesapi.StreamEventTypeOutputItemDone && event.Item != nil && event.Item.Type == "custom_tool_call" { + require.NotNil(t, event.Item.Input) + return *event.Item.Input + } + } + t.Fatal("custom_tool_call output_item.done not found") + return "" +} + +func marshalResponsesChatTestJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + return data +} + +func TestResponsesToChatTools_DropsUnsupportedServerToolWithWarning(t *testing.T) { + ctx := context.Background() + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5","input":"run","tools":[ + {"type":"function","name":"local_lookup","parameters":{"type":"object"}}, + {"type":"future_server_tool","name":"hosted","execution":"server"} + ] + }`)}) + require.NoError(t, err) + + chatOutbound, err := NewOutboundTransformer("https://chat.example.com", "test-key") + require.NoError(t, err) + chatRequest, err := chatOutbound.TransformRequest(ctx, llmRequest) + require.NoError(t, err) + + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Len(t, converted.Tools, 1) + require.Equal(t, "local_lookup", converted.Tools[0].Function.Name) + warnings, ok := chatRequest.TransformerMetadata[responsesChatToolWarningsMetadataKey].([]string) + require.True(t, ok) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "unsupported_tool_type") + require.Contains(t, logs.String(), "Responses request degraded during Chat Completions conversion") + require.Contains(t, logs.String(), `"model":"gpt-5.5"`) + require.Contains(t, logs.String(), "unsupported_tool_type") +} + +func TestResponsesToChatTools_UnsupportedNamedChoiceReturnsBadRequest(t *testing.T) { + ctx := context.Background() + responsesInbound := responsesapi.NewInboundTransformer() + llmRequest, err := responsesInbound.TransformRequest(ctx, &httpclient.Request{Body: []byte(`{ + "model":"gpt-5.5", + "input":"run hosted search", + "tools":[{"type":"tool_search","execution":"server"}], + "tool_choice":{"type":"tool_search","name":"tool_search"} + }`)}) + require.NoError(t, err) + + chatOutbound, err := NewOutboundTransformer("https://chat.example.com", "test-key") + require.NoError(t, err) + _, err = chatOutbound.TransformRequest(ctx, llmRequest) + require.ErrorIs(t, err, transformer.ErrInvalidRequest) + require.ErrorContains(t, err, "unsupported_tool_choice") + + httpErr := responsesInbound.TransformError(ctx, err) + require.Equal(t, http.StatusBadRequest, httpErr.StatusCode) + var responseErr responsesapi.ResponseError + require.NoError(t, json.Unmarshal(httpErr.Body, &responseErr)) + require.Equal(t, "invalid_request_error", responseErr.Error.Type) + require.Contains(t, responseErr.Error.Message, "unsupported_tool_choice") +} + +func TestChatTools_FiltersEstablishedNonChatToolsWithoutCompatibilityWarning(t *testing.T) { + ctx := context.Background() + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + chatOutbound, err := NewOutboundTransformer("https://chat.example.com", "test-key") + require.NoError(t, err) + prompt := "hello" + chatRequest, err := chatOutbound.TransformRequest(ctx, &llm.Request{ + Model: "chat-model", Messages: []llm.Message{{Role: "user", Content: llm.MessageContent{Content: &prompt}}}, + Tools: []llm.Tool{ + {Type: llm.ToolTypeImageGeneration}, + {Type: llm.ToolTypeWebSearch}, + {Type: llm.ToolTypeGoogleSearch}, + {Type: llm.ToolTypeGoogleCodeExecution}, + {Type: llm.ToolTypeGoogleUrlContext}, + }, + }) + require.NoError(t, err) + + var converted Request + require.NoError(t, json.Unmarshal(chatRequest.Body, &converted)) + require.Empty(t, converted.Tools) + _, warned := chatRequest.TransformerMetadata[responsesChatToolWarningsMetadataKey] + require.False(t, warned) + require.NotContains(t, logs.String(), "Responses request degraded during Chat Completions conversion") +} diff --git a/llm/transformer/openai/responses_chat_tools.go b/llm/transformer/openai/responses_chat_tools.go new file mode 100644 index 000000000..0fe7c8e05 --- /dev/null +++ b/llm/transformer/openai/responses_chat_tools.go @@ -0,0 +1,816 @@ +package openai + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/looplj/axonhub/llm" +) + +const ( + responsesChatToolMappingsMetadataKey = "openai_responses_chat_tool_mappings" + responsesChatToolWarningsMetadataKey = "openai_responses_chat_tool_warnings" + responsesChatToolCatalogMetadataKey = "openai_responses_chat_tool_catalog" +) + +type responsesChatToolKind string + +const ( + responsesChatToolCustom responsesChatToolKind = "custom" + responsesChatToolSearch responsesChatToolKind = "tool_search" + responsesChatToolNamespace responsesChatToolKind = "namespace" +) + +type responsesChatToolMapping struct { + Kind responsesChatToolKind + ChatName string + Name string + Namespace string + Execution string + HistoryOnly bool +} + +type responsesChatToolAdapter struct { + byChatName map[string]responsesChatToolMapping + byIdentity map[string]responsesChatToolMapping + usedNames map[string]struct{} + availableNames map[string]struct{} + plainDefinitions map[string]string + mappingSignatures map[string]string + emittedPlain map[string]struct{} + err error + warnings []string +} + +// newResponsesChatToolAdapter indexes existing names and definitions before conversion. +func newResponsesChatToolAdapter(tools []llm.Tool) *responsesChatToolAdapter { + a := &responsesChatToolAdapter{ + byChatName: make(map[string]responsesChatToolMapping), + byIdentity: make(map[string]responsesChatToolMapping), + usedNames: make(map[string]struct{}), + availableNames: make(map[string]struct{}), + plainDefinitions: make(map[string]string), + mappingSignatures: make(map[string]string), + emittedPlain: make(map[string]struct{}), + } + for _, tool := range tools { + if tool.Type == llm.ToolTypeFunction && tool.Function.Namespace == "" { + if tool.Function.Name == "" { + a.addWarning("unsupported_tool_type: dropped function tool without a name") + continue + } + signature, err := functionDefinitionSignature(tool.Function) + if err != nil { + a.addWarning("invalid function tool %q was dropped: %v", tool.Function.Name, err) + continue + } + if previous, exists := a.plainDefinitions[tool.Function.Name]; exists && previous != signature { + a.addWarning("tool_name_conflict: kept first definition of function %q", tool.Function.Name) + continue + } + a.plainDefinitions[tool.Function.Name] = signature + a.usedNames[tool.Function.Name] = struct{}{} + } + } + return a +} + +// convertTools translates callable Responses primitives into Chat function definitions. +func (a *responsesChatToolAdapter) convertTools(tools []llm.Tool) []Tool { + result := make([]Tool, 0, len(tools)) + for _, tool := range tools { + switch tool.Type { + case llm.ToolTypeFunction: + if tool.Function.Name == "" { + if tool.Function.Namespace != "" { + a.addWarning("unsupported_tool_type: dropped namespace function tool without a name") + } + continue + } + normalizedFunction, err := normalizeChatFunctionDefinition(tool.Function) + if err != nil { + if tool.Function.Namespace != "" { + a.addWarning("invalid namespace tool %q was dropped: %v", tool.Function.Name, err) + } + continue + } + tool.Function = normalizedFunction + signature, err := functionDefinitionSignature(tool.Function) + if err != nil { + if tool.Function.Namespace != "" { + a.addWarning("invalid namespace tool %q was dropped: %v", tool.Function.Name, err) + } + continue + } + chatTool := ToolFromLLM(tool) + if tool.Function.Namespace != "" { + name := strings.TrimPrefix(tool.Function.Name, tool.Function.Namespace+"__") + identity := mappingIdentity(responsesChatToolNamespace, name, tool.Function.Namespace) + mapping := responsesChatToolMapping{ + Kind: responsesChatToolNamespace, Name: name, Namespace: tool.Function.Namespace, + } + chatName, duplicate := a.registerMapping(identity, tool.Function.Name, "axonhub_namespace_tool", signature, mapping) + if duplicate { + continue + } + chatTool.Function.Name = chatName + } else { + if selected, ok := a.plainDefinitions[tool.Function.Name]; !ok || selected != signature { + continue + } + if _, emitted := a.emittedPlain[tool.Function.Name]; emitted { + continue + } + a.emittedPlain[tool.Function.Name] = struct{}{} + a.availableNames[tool.Function.Name] = struct{}{} + } + result = append(result, chatTool) + + case llm.ToolTypeResponsesCustomTool: + if tool.ResponseCustomTool == nil { + a.addWarning("unsupported_tool_type: dropped custom tool with missing definition") + continue + } + if tool.ResponseCustomTool.Name == "" { + a.addWarning("unsupported_tool_type: dropped custom tool without a name") + continue + } + identity := mappingIdentity(responsesChatToolCustom, tool.ResponseCustomTool.Name, "") + signature, err := json.Marshal(tool.ResponseCustomTool) + if err != nil { + a.addWarning("invalid custom tool %q was dropped: %v", tool.ResponseCustomTool.Name, err) + continue + } + mapping := responsesChatToolMapping{Kind: responsesChatToolCustom, Name: tool.ResponseCustomTool.Name} + chatName, duplicate := a.registerMapping(identity, tool.ResponseCustomTool.Name, "axonhub_custom_tool", string(signature), mapping) + if duplicate { + continue + } + if format := tool.ResponseCustomTool.Format; format != nil && + (strings.EqualFold(strings.TrimSpace(format.Type), "grammar") || format.Definition != "") { + a.addWarning("grammar_constraint_degraded: custom tool %q grammar is advisory after conversion to Chat Completions", tool.ResponseCustomTool.Name) + } + description := responsesChatCustomToolDescription(tool.ResponseCustomTool) + result = append(result, Tool{ + Type: llm.ToolTypeFunction, + Function: Function{ + Name: chatName, Description: description, + Parameters: json.RawMessage(`{"type":"object","properties":{"input":{"type":"string","description":"Exact raw custom-tool input. Escape quotes, backslashes, and control characters only as required for the outer JSON string; do not add another object or serialization layer."}},"required":["input"],"additionalProperties":false}`), + }, + }) + case llm.ToolTypeResponsesToolSearch: + if tool.ResponseToolSearch == nil { + a.addWarning("unsupported_tool_type: dropped tool_search with missing definition") + continue + } + if tool.ResponseToolSearch.Execution != "client" { + a.addWarning("unsupported_execution_owner: dropped tool_search with execution %q", tool.ResponseToolSearch.Execution) + continue + } + parameters, err := normalizeChatFunctionParameters(tool.ResponseToolSearch.Parameters) + if err != nil { + a.addWarning("invalid tool_search definition was dropped: %v", err) + continue + } + normalizedToolSearch := *tool.ResponseToolSearch + normalizedToolSearch.Parameters = parameters + identity := mappingIdentity(responsesChatToolSearch, "tool_search", "") + signature, err := json.Marshal(&normalizedToolSearch) + if err != nil { + a.addWarning("invalid tool_search definition was dropped: %v", err) + continue + } + mapping := responsesChatToolMapping{ + Kind: responsesChatToolSearch, Name: "tool_search", Execution: tool.ResponseToolSearch.Execution, + } + chatName, duplicate := a.registerMapping(identity, "tool_search", "axonhub_tool_search", string(signature), mapping) + if duplicate { + continue + } + result = append(result, Tool{ + Type: llm.ToolTypeFunction, + Function: Function{ + Name: chatName, Description: normalizedToolSearch.Description, Parameters: parameters, + }, + }) + + case llm.ToolTypeResponsesOpaqueTool: + if tool.ResponseOpaqueTool == nil { + a.addWarning("unsupported_tool_type: dropped opaque Responses tool with missing definition") + continue + } + a.addWarning("unsupported_tool_type: dropped Responses tool %q (%s) without a Chat lifecycle codec", tool.ResponseOpaqueTool.Name, tool.ResponseOpaqueTool.SourceType) + + case llm.ToolTypeImageGeneration, llm.ToolTypeWebSearch, llm.ToolTypeGoogleSearch, + llm.ToolTypeGoogleCodeExecution, llm.ToolTypeGoogleUrlContext: + // These common-model tools already predate the Responses-to-Chat bridge. + // Chat Completions cannot carry them, so retain the legacy silent filter + // instead of reporting a Responses compatibility degradation. + continue + + default: + a.addWarning("unsupported_tool_type: dropped %s tool that cannot be translated to Chat Completions", tool.Type) + } + } + return result +} + +// responsesChatCustomToolDescription explains the JSON function wrapper and +// preserves original instructions while marking grammar as advisory. +func responsesChatCustomToolDescription(tool *llm.ResponseCustomTool) string { + const wrapperInstructions = "This Responses custom tool is represented as a Chat Completions function. " + + "Pass the exact raw custom-tool input as the string value of the `input` argument. " + + "The outer function arguments must be valid JSON: escape quotes, backslashes, and control characters " + + "only as required for that JSON string. Do not add Markdown code fences. " + + "Any original instruction below about not wrapping input in JSON applies to the `input` string itself; " + + "do not add another object or serialization layer inside it." + + var description strings.Builder + description.WriteString(wrapperInstructions) + if original := strings.TrimSpace(tool.Description); original != "" { + description.WriteString("\n\nOriginal custom-tool instructions (apply to the `input` string):\n") + description.WriteString(original) + } + if format := tool.Format; format != nil && format.Definition != "" { + description.WriteString("\n\nChat Completions cannot enforce the original custom-tool grammar during sampling. " + + "Treat this grammar as required guidance for the `input` string:\n") + if syntax := strings.TrimSpace(format.Syntax); syntax != "" { + description.WriteString(syntax) + description.WriteString(" grammar:\n") + } else { + description.WriteString("grammar:\n") + } + description.WriteString(format.Definition) + } + return description.String() +} + +// convertMessage translates specialized Responses calls in message history into Chat calls. +func (a *responsesChatToolAdapter) convertMessage(message llm.Message, reasoningField ReasoningField) Message { + converted := MessageFromLLMWithConfig(message, reasoningField) + if len(message.ToolCalls) == 0 { + return converted + } + + converted.ToolCalls = make([]ToolCall, 0, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + switch { + case call.ResponseCustomToolCall != nil: + mapping, ok := a.findMapping(responsesChatToolCustom, call.ResponseCustomToolCall.Name, "") + if !ok { + mapping = a.registerHistoryMapping( + mappingIdentity(responsesChatToolCustom, call.ResponseCustomToolCall.Name, ""), + call.ResponseCustomToolCall.Name, + "axonhub_custom_tool_history", + responsesChatToolMapping{Kind: responsesChatToolCustom, Name: call.ResponseCustomToolCall.Name}, + ) + } + arguments, _ := json.Marshal(map[string]string{"input": call.ResponseCustomToolCall.Input}) + converted.ToolCalls = append(converted.ToolCalls, chatFunctionCall(call, a.specialCallID(call, call.ResponseCustomToolCall.CallID), mapping.ChatName, string(arguments))) + + case call.ResponseToolSearchCall != nil: + mapping, ok := a.findMapping(responsesChatToolSearch, "tool_search", "") + if !ok { + mapping = a.registerHistoryMapping( + mappingIdentity(responsesChatToolSearch, "tool_search", ""), + "tool_search", + "axonhub_tool_search_history", + responsesChatToolMapping{ + Kind: responsesChatToolSearch, Name: "tool_search", Execution: call.ResponseToolSearchCall.Execution, + }, + ) + } + converted.ToolCalls = append(converted.ToolCalls, chatFunctionCall(call, a.specialCallID(call, call.ResponseToolSearchCall.CallID), mapping.ChatName, call.ResponseToolSearchCall.Arguments)) + + default: + name := call.Function.Name + if call.Function.Namespace != "" { + name = call.Function.Namespace + "__" + call.Function.Name + if mapping, ok := a.findMapping(responsesChatToolNamespace, call.Function.Name, call.Function.Namespace); ok { + name = mapping.ChatName + } + } + convertedCall := ToolCallFromLLM(call) + convertedCall.Type = llm.ToolTypeFunction + convertedCall.Function.Name = name + converted.ToolCalls = append(converted.ToolCalls, convertedCall) + } + } + return converted +} + +// hasChatAssistantPayload reports whether a converted message contains data +// accepted as assistant history by Chat Completions providers. Non-assistant +// roles use different validation rules and pass through unchanged. +func hasChatAssistantPayload(message Message) bool { + if message.Role != "assistant" { + return true + } + if len(message.ToolCalls) > 0 || + (message.ReasoningContent != nil && strings.TrimSpace(*message.ReasoningContent) != "") || + (message.Reasoning != nil && strings.TrimSpace(*message.Reasoning) != "") || + strings.TrimSpace(message.Refusal) != "" || message.Audio != nil { + return true + } + if message.Content.Content != nil && strings.TrimSpace(*message.Content.Content) != "" { + return true + } + for _, part := range message.Content.MultipleContent { + if (part.Text != nil && strings.TrimSpace(*part.Text) != "") || + part.ImageURL != nil || part.VideoURL != nil || part.InputAudio != nil { + return true + } + } + return false +} + +// chatFunctionCall builds a Chat function call while preserving common call metadata. +func chatFunctionCall(call llm.ToolCall, callID, name, arguments string) ToolCall { + return ToolCall{ + ID: callID, Type: llm.ToolTypeFunction, Index: call.Index, + Function: FunctionCall{Name: name, Arguments: arguments}, + } +} + +// convertToolChoice maps Responses tool selection onto the converted Chat catalog. +func (a *responsesChatToolAdapter) convertToolChoice(choice *llm.ToolChoice) *ToolChoice { + if choice == nil { + return nil + } + result := &ToolChoice{ToolChoice: choice.ToolChoice} + if choice.NamedToolChoice == nil { + return result + } + name := choice.NamedToolChoice.Function.Name + kind := choiceMappingKind(choice.NamedToolChoice.Type) + switch { + case kind != "": + mapping, ok := a.findToolChoiceMapping(kind, name) + if !ok { + a.setError(fmt.Errorf("unsupported_tool_choice: named tool %q is unavailable after Responses-to-Chat conversion", name)) + return nil + } + name = mapping.ChatName + case choice.NamedToolChoice.Type == llm.ToolTypeFunction: + if mapping, ok := a.findNamespaceToolChoiceMapping(name); ok { + if _, plain := a.emittedPlain[name]; plain && mapping.ChatName != name { + a.setError(fmt.Errorf("tool_name_conflict: function tool choice %q is ambiguous between plain and namespace tools", name)) + return nil + } + name = mapping.ChatName + } + case choice.NamedToolChoice.Type != "" && choice.NamedToolChoice.Type != llm.ToolTypeFunction: + a.setError(fmt.Errorf("unsupported_tool_choice: named %s tool %q cannot be translated to Chat Completions", choice.NamedToolChoice.Type, name)) + return nil + } + if _, ok := a.availableNames[name]; !ok { + a.setError(fmt.Errorf("unsupported_tool_choice: named tool %q is unavailable after Responses-to-Chat conversion", choice.NamedToolChoice.Function.Name)) + return nil + } + result.NamedToolChoice = &NamedToolChoice{Type: llm.ToolTypeFunction, Function: ToolFunction{Name: name}} + return result +} + +// reserveName returns a collision-free Chat function name. +func (a *responsesChatToolAdapter) reserveName(preferred, fallback string) string { + if preferred == "" { + preferred = fallback + } + if _, exists := a.usedNames[preferred]; !exists { + a.usedNames[preferred] = struct{}{} + return preferred + } + for i := 1; ; i++ { + candidate := fmt.Sprintf("%s_%d", fallback, i) + if _, exists := a.usedNames[candidate]; !exists { + a.usedNames[candidate] = struct{}{} + return candidate + } + } +} + +// registerMapping records the reversible identity of a converted tool definition. +func (a *responsesChatToolAdapter) registerMapping(identity, preferred, fallback, signature string, mapping responsesChatToolMapping) (string, bool) { + if existing, ok := a.byIdentity[identity]; ok { + if existingSignature := a.mappingSignatures[identity]; existingSignature != signature { + a.addWarning("tool_name_conflict: kept first definition of tool %q", mapping.Name) + } + return existing.ChatName, true + } + chatName := a.reserveName(preferred, fallback) + mapping.ChatName = chatName + a.byIdentity[identity] = mapping + a.byChatName[chatName] = mapping + a.availableNames[chatName] = struct{}{} + a.mappingSignatures[identity] = signature + return chatName, false +} + +// registerHistoryMapping records a non-callable mapping used only to replay history. +func (a *responsesChatToolAdapter) registerHistoryMapping( + identity, preferred, fallback string, + mapping responsesChatToolMapping, +) responsesChatToolMapping { + if existing, ok := a.byIdentity[identity]; ok { + return existing + } + mapping.ChatName = a.reserveName(preferred, fallback) + mapping.HistoryOnly = true + a.byIdentity[identity] = mapping + a.byChatName[mapping.ChatName] = mapping + return mapping +} + +// findMapping looks up a converted tool by its Responses identity. +func (a *responsesChatToolAdapter) findMapping(kind responsesChatToolKind, name, namespace string) (responsesChatToolMapping, bool) { + mapping, ok := a.byIdentity[mappingIdentity(kind, name, namespace)] + return mapping, ok +} + +// findToolChoiceMapping resolves a named choice to one unambiguous converted tool. +func (a *responsesChatToolAdapter) findToolChoiceMapping(kind responsesChatToolKind, name string) (responsesChatToolMapping, bool) { + if kind == responsesChatToolSearch { + return a.findMapping(kind, "tool_search", "") + } + if kind == responsesChatToolCustom { + return a.findMapping(kind, name, "") + } + if kind == responsesChatToolNamespace { + return a.findNamespaceToolChoiceMapping(name) + } + return responsesChatToolMapping{}, false +} + +// findNamespaceToolChoiceMapping resolves member or flattened namespace names. +func (a *responsesChatToolAdapter) findNamespaceToolChoiceMapping(name string) (responsesChatToolMapping, bool) { + var found responsesChatToolMapping + matched := false + for _, mapping := range a.byIdentity { + if mapping.Kind != responsesChatToolNamespace || + (mapping.ChatName != name && mapping.Name != name && mapping.Namespace+"__"+mapping.Name != name) { + continue + } + if matched { + a.setError(fmt.Errorf("tool_name_conflict: namespace tool choice %q is ambiguous", name)) + return responsesChatToolMapping{}, false + } + found = mapping + matched = true + } + return found, matched +} + +// specialCallID selects the protocol-specific call ID and reports inconsistencies. +func (a *responsesChatToolAdapter) specialCallID(call llm.ToolCall, specialized string) string { + if specialized != "" { + if call.ID != "" && specialized != call.ID { + a.addWarning("tool_call_id_conflict: used specialized call ID %q instead of outer call ID %q", specialized, call.ID) + } + return specialized + } + return call.ID +} + +// addWarning records a non-fatal conversion degradation. +func (a *responsesChatToolAdapter) addWarning(format string, args ...any) { + a.warnings = append(a.warnings, fmt.Sprintf(format, args...)) +} + +// setError retains the first fatal conversion error. +func (a *responsesChatToolAdapter) setError(err error) { + if a.err == nil && err != nil { + a.err = err + } +} + +// mappingIdentity returns the stable internal key for a Responses tool identity. +func mappingIdentity(kind responsesChatToolKind, name, namespace string) string { + return string(kind) + "\x00" + namespace + "\x00" + name +} + +// choiceMappingKind maps a Responses tool-choice type to its adapter category. +func choiceMappingKind(toolType string) responsesChatToolKind { + switch toolType { + case "custom", llm.ToolTypeResponsesCustomTool: + return responsesChatToolCustom + case "tool_search", llm.ToolTypeResponsesToolSearch: + return responsesChatToolSearch + case "namespace": + return responsesChatToolNamespace + default: + return "" + } +} + +// normalizeChatFunctionDefinition ensures the parameter root satisfies the +// object-only contract required by Chat Completions function tools. +func normalizeChatFunctionDefinition(function llm.Function) (llm.Function, error) { + parameters, err := normalizeChatFunctionParameters(function.Parameters) + if err != nil { + return llm.Function{}, err + } + function.Parameters = parameters + return function, nil +} + +func normalizeChatFunctionParameters(parameters json.RawMessage) (json.RawMessage, error) { + const emptyObjectSchema = `{"type":"object","properties":{}}` + + trimmed := strings.TrimSpace(string(parameters)) + if trimmed == "" || trimmed == "null" { + return json.RawMessage(emptyObjectSchema), nil + } + + var schema map[string]json.RawMessage + if err := json.Unmarshal(parameters, &schema); err != nil { + return nil, fmt.Errorf("parameters must be a JSON object: %w", err) + } + if schema == nil { + return json.RawMessage(emptyObjectSchema), nil + } + + rawType, hasType := schema["type"] + if !hasType { + schema["type"] = json.RawMessage(`"object"`) + normalized, err := json.Marshal(schema) + if err != nil { + return nil, fmt.Errorf("failed to normalize parameters schema: %w", err) + } + return normalized, nil + } + + var schemaType string + if err := json.Unmarshal(rawType, &schemaType); err != nil || schemaType != "object" { + return nil, fmt.Errorf("parameters schema type is required and must be %q", "object") + } + return parameters, nil +} + +// functionDefinitionSignature normalizes a function definition for deduplication. +func functionDefinitionSignature(function llm.Function) (string, error) { + function, err := normalizeChatFunctionDefinition(function) + if err != nil { + return "", err + } + var parameters any + if len(function.Parameters) > 0 { + if err := json.Unmarshal(function.Parameters, ¶meters); err != nil { + return "", err + } + } + normalized, err := json.Marshal(struct { + Description string `json:"description,omitempty"` + Parameters any `json:"parameters"` + Strict *bool `json:"strict,omitempty"` + }{Description: function.Description, Parameters: parameters, Strict: function.Strict}) + if err != nil { + return "", err + } + return string(normalized), nil +} + +// mappings returns an isolated Chat-name lookup table for response restoration. +func (a *responsesChatToolAdapter) mappings() map[string]responsesChatToolMapping { + if len(a.byChatName) == 0 { + return nil + } + result := make(map[string]responsesChatToolMapping, len(a.byChatName)) + for name, mapping := range a.byChatName { + result[name] = mapping + } + return result +} + +// catalog returns the sorted callable Chat tool names used to recognize name fragments. +func (a *responsesChatToolAdapter) catalog() []string { + if len(a.availableNames) == 0 { + return nil + } + result := make([]string, 0, len(a.availableNames)) + for name := range a.availableNames { + result = append(result, name) + } + sort.Strings(result) + return result +} + +// restoreResponsesChatToolCalls restores specialized Responses calls in a full response. +func restoreResponsesChatToolCalls(response *llm.Response, mappings map[string]responsesChatToolMapping) { + if response == nil || len(mappings) == 0 { + return + } + for i := range response.Choices { + if response.Choices[i].Message != nil { + restoreResponsesChatMessage(response.Choices[i].Message, mappings, true) + } + if response.Choices[i].Delta != nil { + restoreResponsesChatMessage(response.Choices[i].Delta, mappings, true) + } + } +} + +type responsesChatToolStreamRestorer struct { + mappings map[string]responsesChatToolMapping + catalog map[string]struct{} + byIndex map[[2]int]responsesChatToolMapping + pending map[[2]int]llm.ToolCall + plain map[[2]int]struct{} +} + +// newResponsesChatToolStreamRestorer creates state for restoring fragmented Chat calls. +func newResponsesChatToolStreamRestorer( + mappings map[string]responsesChatToolMapping, + catalogs ...[]string, +) *responsesChatToolStreamRestorer { + restorer := &responsesChatToolStreamRestorer{ + mappings: mappings, + catalog: map[string]struct{}{}, + byIndex: map[[2]int]responsesChatToolMapping{}, + pending: map[[2]int]llm.ToolCall{}, + plain: map[[2]int]struct{}{}, + } + for _, catalog := range catalogs { + for _, name := range catalog { + restorer.catalog[name] = struct{}{} + } + } + return restorer +} + +// restore restores specialized calls and tracks their names across stream chunks. +func (r *responsesChatToolStreamRestorer) restore(response *llm.Response) { + if response == nil || (len(r.mappings) == 0 && len(r.catalog) == 0) { + return + } + for i := range response.Choices { + choice := &response.Choices[i] + message := choice.Delta + if message == nil { + message = choice.Message + } + if message != nil { + restored := make([]llm.ToolCall, 0, len(message.ToolCalls)) + for j := range message.ToolCalls { + call := message.ToolCalls[j] + key := [2]int{choice.Index, call.Index} + if mapping, ok := r.byIndex[key]; ok { + call.Function.Name = mapping.ChatName + restored = append(restored, call) + continue + } + if _, ok := r.plain[key]; ok { + restored = append(restored, call) + continue + } + + currentName := call.Function.Name + if pending, ok := r.pending[key]; ok { + call = mergeResponsesChatToolCallFragments(pending, call) + if !r.isKnownName(call.Function.Name) && r.isKnownName(currentName) { + call.Function.Name = currentName + } + } + potentialLongerName := r.isPotentialKnownName(call.Function.Name) + if mapping, ok := r.mappings[call.Function.Name]; ok && !mapping.HistoryOnly { + if call.ID == "" || potentialLongerName { + r.pending[key] = call + continue + } + r.byIndex[key] = mapping + delete(r.pending, key) + restored = append(restored, call) + continue + } + if _, exact := r.catalog[call.Function.Name]; exact && call.ID != "" && !potentialLongerName { + delete(r.pending, key) + r.plain[key] = struct{}{} + restored = append(restored, call) + continue + } + if call.ID == "" || potentialLongerName { + r.pending[key] = call + continue + } + delete(r.pending, key) + r.plain[key] = struct{}{} + restored = append(restored, call) + } + message.ToolCalls = restored + } + + if choice.FinishReason != nil { + if message == nil { + message = &llm.Message{} + choice.Delta = message + } + pendingIndexes := make([]int, 0) + for key := range r.pending { + if key[0] == choice.Index { + pendingIndexes = append(pendingIndexes, key[1]) + } + } + sort.Ints(pendingIndexes) + for _, index := range pendingIndexes { + key := [2]int{choice.Index, index} + call := r.pending[key] + if mapping, ok := r.mappings[call.Function.Name]; ok && !mapping.HistoryOnly { + r.byIndex[key] = mapping + } else { + r.plain[key] = struct{}{} + } + message.ToolCalls = append(message.ToolCalls, call) + delete(r.pending, key) + } + } + if message != nil { + restoreResponsesChatMessage(message, r.mappings, false) + } + } +} + +// isKnownName reports whether a name exactly identifies a declared callable tool. +func (r *responsesChatToolStreamRestorer) isKnownName(name string) bool { + if mapping, ok := r.mappings[name]; ok && !mapping.HistoryOnly { + return true + } + _, ok := r.catalog[name] + return ok +} + +// isPotentialKnownName reports whether a partial name can become a declared tool name. +func (r *responsesChatToolStreamRestorer) isPotentialKnownName(name string) bool { + for knownName := range r.catalog { + if knownName != name && strings.HasPrefix(knownName, name) { + return true + } + } + for chatName, mapping := range r.mappings { + if !mapping.HistoryOnly && chatName != name && strings.HasPrefix(chatName, name) { + return true + } + } + return false +} + +// mergeResponsesChatToolCallFragments accumulates identity, name, and arguments deltas. +func mergeResponsesChatToolCallFragments(pending, current llm.ToolCall) llm.ToolCall { + merged := pending + if current.ID != "" { + merged.ID = current.ID + } + if current.Type != "" { + merged.Type = current.Type + } + merged.Index = current.Index + merged.Function.Name += current.Function.Name + merged.Function.Arguments += current.Function.Arguments + if current.Function.Namespace != "" { + merged.Function.Namespace = current.Function.Namespace + } + if len(current.TransformerMetadata) > 0 { + if merged.TransformerMetadata == nil { + merged.TransformerMetadata = map[string]any{} + } + for key, value := range current.TransformerMetadata { + merged.TransformerMetadata[key] = value + } + } + return merged +} + +// restoreResponsesChatMessage restores specialized calls in one message or delta. +func restoreResponsesChatMessage(message *llm.Message, mappings map[string]responsesChatToolMapping, unwrapCustom bool) { + for i := range message.ToolCalls { + call := &message.ToolCalls[i] + mapping, ok := mappings[call.Function.Name] + if !ok || mapping.HistoryOnly { + continue + } + switch mapping.Kind { + case responsesChatToolCustom: + input := call.Function.Arguments + if unwrapCustom { + var wrapper struct { + Input *string `json:"input"` + } + if json.Unmarshal([]byte(call.Function.Arguments), &wrapper) == nil && wrapper.Input != nil { + input = *wrapper.Input + } + } + call.Type = llm.ToolTypeResponsesCustomTool + call.ResponseCustomToolCall = &llm.ResponseCustomToolCall{CallID: call.ID, Name: mapping.Name, Input: input} + if call.TransformerMetadata == nil { + call.TransformerMetadata = map[string]any{} + } + call.TransformerMetadata["openai_responses_chat_wrapped_custom"] = true + case responsesChatToolSearch: + call.Type = llm.ToolTypeResponsesToolSearch + call.ResponseToolSearchCall = &llm.ResponseToolSearchCall{CallID: call.ID, Execution: mapping.Execution, Arguments: call.Function.Arguments} + case responsesChatToolNamespace: + call.Function.Name = mapping.Name + call.Function.Namespace = mapping.Namespace + } + } +}