From 0c0e93e6ea41b36e86d3925f5c8803417b56ae3c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:33:32 +0000 Subject: [PATCH 01/72] test(fixtures): record AI SDK protocol fixtures under a mocked fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI SDK alignment design (docs/aisdk-architecture-alignment.md §9.3) requires validating aimux providers against what the AI SDK sends and returns for the same input under a mocked transport. This adds the SDK half: scripts/aisdk-fixtures runs the official @ai-sdk packages (ai 7.0.127, provider 4.0.21, provider-utils 5.0.53, openai 4.0.83, openai-compatible 3.0.62, anthropic 4.0.71, google 4.0.87, pinned exactly) with a recording fetch and writes fixtures/aisdk//.json: request (URL, headers with credentials redacted, body), canned response, and the SDK's normalized result or thrown error. 28 cases across openai, openai-compatible (name "groq"), anthropic and google pin the behaviours the Rust provider rewrite must match, among them: an explicit empty apiKey is sent verbatim and never falls back to the env var; a missing key fails at call time with AI_LoadAPIKeyError and no request; createAnthropic({ name }) sets model.provider to the name verbatim (default "anthropic.messages") and reads providerOptions under both the canonical and the custom key, custom winning; openai-compatible derives its providerOptions key from the first segment of model.provider and spreads unknown fields under that key into the request body; header merging is case-insensitive with call-level headers overriding provider headers. Rust tests consume these in the provider factory rewrite; regenerate with `cd scripts/aisdk-fixtures && npm ci && node record.mjs`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- .gitignore | 3 + fixtures/aisdk/VERSIONS.json | 9 + fixtures/aisdk/anthropic/messages-basic.json | 108 ++++++++ .../aisdk/anthropic/messages-custom-name.json | 141 ++++++++++ .../anthropic/messages-provider-options.json | 135 ++++++++++ .../anthropic/messages-stream-basic.json | 166 ++++++++++++ .../anthropic/messages-system-tools.json | 166 ++++++++++++ fixtures/aisdk/google/embedding-basic.json | 94 +++++++ fixtures/aisdk/google/generate-basic.json | 113 ++++++++ .../google/generate-provider-options.json | 138 ++++++++++ .../aisdk/google/generate-stream-basic.json | 172 ++++++++++++ fixtures/aisdk/google/generate-tools.json | 169 ++++++++++++ .../aisdk/openai-compatible/chat-basic.json | 95 +++++++ .../openai-compatible/chat-no-api-key.json | 97 +++++++ ...-provider-options-generic-key-unknown.json | 105 ++++++++ .../chat-provider-options-namespace.json | 110 ++++++++ .../chat-query-params-and-headers.json | 109 ++++++++ .../openai-compatible/chat-stream-basic.json | 157 +++++++++++ .../openai-compatible/embedding-basic.json | 86 ++++++ fixtures/aisdk/openai/chat-basic.json | 104 ++++++++ fixtures/aisdk/openai/chat-custom-name.json | 107 ++++++++ fixtures/aisdk/openai/chat-headers-merge.json | 119 +++++++++ .../aisdk/openai/chat-provider-options.json | 153 +++++++++++ fixtures/aisdk/openai/chat-stream-basic.json | 160 +++++++++++ .../openai/chat-system-and-settings.json | 130 +++++++++ fixtures/aisdk/openai/chat-tools.json | 158 +++++++++++ fixtures/aisdk/openai/embedding-basic.json | 86 ++++++ fixtures/aisdk/openai/empty-api-key.json | 109 ++++++++ fixtures/aisdk/openai/missing-api-key.json | 24 ++ fixtures/aisdk/openai/responses-basic.json | 120 +++++++++ scripts/aisdk-fixtures/README.md | 61 +++++ scripts/aisdk-fixtures/cases/anthropic.mjs | 111 ++++++++ scripts/aisdk-fixtures/cases/google.mjs | 87 ++++++ .../cases/openai-compatible.mjs | 116 ++++++++ scripts/aisdk-fixtures/cases/openai.mjs | 233 ++++++++++++++++ scripts/aisdk-fixtures/common.mjs | 27 ++ scripts/aisdk-fixtures/mock-fetch.mjs | 81 ++++++ scripts/aisdk-fixtures/package-lock.json | 205 ++++++++++++++ scripts/aisdk-fixtures/package.json | 19 ++ scripts/aisdk-fixtures/record.mjs | 252 ++++++++++++++++++ 40 files changed, 4635 insertions(+) create mode 100644 fixtures/aisdk/VERSIONS.json create mode 100644 fixtures/aisdk/anthropic/messages-basic.json create mode 100644 fixtures/aisdk/anthropic/messages-custom-name.json create mode 100644 fixtures/aisdk/anthropic/messages-provider-options.json create mode 100644 fixtures/aisdk/anthropic/messages-stream-basic.json create mode 100644 fixtures/aisdk/anthropic/messages-system-tools.json create mode 100644 fixtures/aisdk/google/embedding-basic.json create mode 100644 fixtures/aisdk/google/generate-basic.json create mode 100644 fixtures/aisdk/google/generate-provider-options.json create mode 100644 fixtures/aisdk/google/generate-stream-basic.json create mode 100644 fixtures/aisdk/google/generate-tools.json create mode 100644 fixtures/aisdk/openai-compatible/chat-basic.json create mode 100644 fixtures/aisdk/openai-compatible/chat-no-api-key.json create mode 100644 fixtures/aisdk/openai-compatible/chat-provider-options-generic-key-unknown.json create mode 100644 fixtures/aisdk/openai-compatible/chat-provider-options-namespace.json create mode 100644 fixtures/aisdk/openai-compatible/chat-query-params-and-headers.json create mode 100644 fixtures/aisdk/openai-compatible/chat-stream-basic.json create mode 100644 fixtures/aisdk/openai-compatible/embedding-basic.json create mode 100644 fixtures/aisdk/openai/chat-basic.json create mode 100644 fixtures/aisdk/openai/chat-custom-name.json create mode 100644 fixtures/aisdk/openai/chat-headers-merge.json create mode 100644 fixtures/aisdk/openai/chat-provider-options.json create mode 100644 fixtures/aisdk/openai/chat-stream-basic.json create mode 100644 fixtures/aisdk/openai/chat-system-and-settings.json create mode 100644 fixtures/aisdk/openai/chat-tools.json create mode 100644 fixtures/aisdk/openai/embedding-basic.json create mode 100644 fixtures/aisdk/openai/empty-api-key.json create mode 100644 fixtures/aisdk/openai/missing-api-key.json create mode 100644 fixtures/aisdk/openai/responses-basic.json create mode 100644 scripts/aisdk-fixtures/README.md create mode 100644 scripts/aisdk-fixtures/cases/anthropic.mjs create mode 100644 scripts/aisdk-fixtures/cases/google.mjs create mode 100644 scripts/aisdk-fixtures/cases/openai-compatible.mjs create mode 100644 scripts/aisdk-fixtures/cases/openai.mjs create mode 100644 scripts/aisdk-fixtures/common.mjs create mode 100644 scripts/aisdk-fixtures/mock-fetch.mjs create mode 100644 scripts/aisdk-fixtures/package-lock.json create mode 100644 scripts/aisdk-fixtures/package.json create mode 100644 scripts/aisdk-fixtures/record.mjs diff --git a/.gitignore b/.gitignore index 6e6fabbd4..6567f9180 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ quirks-corpus/ # Generated audit artifacts — keep conclusions in Markdown, not raw output. /docs/quality-audit/**/lcov.info /docs/quality-audit/**/*.log + +# AI SDK protocol fixture recorder (scripts/aisdk-fixtures) — npm install output +scripts/aisdk-fixtures/node_modules/ diff --git a/fixtures/aisdk/VERSIONS.json b/fixtures/aisdk/VERSIONS.json new file mode 100644 index 000000000..f4f49d7ba --- /dev/null +++ b/fixtures/aisdk/VERSIONS.json @@ -0,0 +1,9 @@ +{ + "@ai-sdk/anthropic": "4.0.71", + "@ai-sdk/google": "4.0.87", + "@ai-sdk/openai": "4.0.83", + "@ai-sdk/openai-compatible": "3.0.62", + "@ai-sdk/provider": "4.0.21", + "@ai-sdk/provider-utils": "5.0.53", + "ai": "7.0.127" +} diff --git a/fixtures/aisdk/anthropic/messages-basic.json b/fixtures/aisdk/anthropic/messages-basic.json new file mode 100644 index 000000000..51d4e4c90 --- /dev/null +++ b/fixtures/aisdk/anthropic/messages-basic.json @@ -0,0 +1,108 @@ +{ + "case": "messages-basic", + "model": { + "modelId": "claude-sonnet-4-5", + "provider": "anthropic.messages" + }, + "package": "@ai-sdk/anthropic", + "request": { + "body": { + "max_tokens": 64000, + "messages": [ + { + "content": [ + { + "text": "Say hello.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-sonnet-4-5" + }, + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-api-key": "" + }, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages" + }, + "response": { + "body": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "id": "msg_fixture_1", + "model": "claude-sonnet-4-5", + "role": "assistant", + "stop_reason": "end_turn", + "stop_sequence": null, + "type": "message", + "usage": { + "input_tokens": 12, + "output_tokens": 9 + } + }, + "headers": { + "content-type": "application/json", + "request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "anthropic": { + "container": null, + "contextManagement": null, + "iterations": null, + "stopSequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 9 + } + } + }, + "rawFinishReason": "end_turn", + "response": { + "headers": { + "content-type": "application/json", + "request-id": "req_fixture_1" + }, + "id": "msg_fixture_1", + "modelId": "claude-sonnet-4-5" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": {}, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "4.0.71" +} diff --git a/fixtures/aisdk/anthropic/messages-custom-name.json b/fixtures/aisdk/anthropic/messages-custom-name.json new file mode 100644 index 000000000..579a40f11 --- /dev/null +++ b/fixtures/aisdk/anthropic/messages-custom-name.json @@ -0,0 +1,141 @@ +{ + "case": "messages-custom-name", + "model": { + "modelId": "claude-sonnet-4-5", + "provider": "myproxy" + }, + "observations": { + "bodyMetadataUserId": "from-myproxy-key", + "modelProvider": "myproxy", + "providerMetadataKeys": [ + "anthropic", + "myproxy" + ] + }, + "package": "@ai-sdk/anthropic", + "request": { + "body": { + "max_tokens": 64000, + "messages": [ + { + "content": [ + { + "text": "Say hello.", + "type": "text" + } + ], + "role": "user" + } + ], + "metadata": { + "user_id": "from-myproxy-key" + }, + "model": "claude-sonnet-4-5" + }, + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-api-key": "" + }, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages" + }, + "response": { + "body": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "id": "msg_fixture_1", + "model": "claude-sonnet-4-5", + "role": "assistant", + "stop_reason": "end_turn", + "stop_sequence": null, + "type": "message", + "usage": { + "input_tokens": 12, + "output_tokens": 9 + } + }, + "headers": { + "content-type": "application/json", + "request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "anthropic": { + "container": null, + "contextManagement": null, + "iterations": null, + "stopSequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 9 + } + }, + "myproxy": { + "container": null, + "contextManagement": null, + "iterations": null, + "stopSequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 9 + } + } + }, + "rawFinishReason": "end_turn", + "response": { + "headers": { + "content-type": "application/json", + "request-id": "req_fixture_1" + }, + "id": "msg_fixture_1", + "modelId": "claude-sonnet-4-5" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": {}, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello.", + "providerOptions": { + "anthropic": { + "metadata": { + "userId": "from-anthropic-key" + } + }, + "myproxy": { + "metadata": { + "userId": "from-myproxy-key" + } + } + } + }, + "operation": "generateText" + }, + "version": "4.0.71" +} diff --git a/fixtures/aisdk/anthropic/messages-provider-options.json b/fixtures/aisdk/anthropic/messages-provider-options.json new file mode 100644 index 000000000..5750690b6 --- /dev/null +++ b/fixtures/aisdk/anthropic/messages-provider-options.json @@ -0,0 +1,135 @@ +{ + "case": "messages-provider-options", + "model": { + "modelId": "claude-sonnet-4-5", + "provider": "anthropic.messages" + }, + "package": "@ai-sdk/anthropic", + "request": { + "body": { + "max_tokens": 6096, + "messages": [ + { + "content": [ + { + "text": "What is 17 * 23?", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-sonnet-4-5", + "thinking": { + "budget_tokens": 2000, + "type": "enabled" + } + }, + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-api-key": "" + }, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages" + }, + "response": { + "body": { + "content": [ + { + "signature": "sig_fixture", + "thinking": "17 * 23 = 391.", + "type": "thinking" + }, + { + "text": "391", + "type": "text" + } + ], + "id": "msg_fixture_1", + "model": "claude-sonnet-4-5", + "role": "assistant", + "stop_reason": "end_turn", + "stop_sequence": null, + "type": "message", + "usage": { + "input_tokens": 12, + "output_tokens": 9 + } + }, + "headers": { + "content-type": "application/json", + "request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "providerMetadata": { + "anthropic": { + "signature": "sig_fixture" + } + }, + "text": "17 * 23 = 391.", + "type": "reasoning" + }, + { + "text": "391", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "anthropic": { + "container": null, + "contextManagement": null, + "iterations": null, + "stopSequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 9 + } + } + }, + "rawFinishReason": "end_turn", + "response": { + "headers": { + "content-type": "application/json", + "request-id": "req_fixture_1" + }, + "id": "msg_fixture_1", + "modelId": "claude-sonnet-4-5" + }, + "text": "391", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": {}, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "maxOutputTokens": 4096, + "prompt": "What is 17 * 23?", + "providerOptions": { + "anthropic": { + "thinking": { + "budgetTokens": 2000, + "type": "enabled" + } + } + } + }, + "operation": "generateText" + }, + "version": "4.0.71" +} diff --git a/fixtures/aisdk/anthropic/messages-stream-basic.json b/fixtures/aisdk/anthropic/messages-stream-basic.json new file mode 100644 index 000000000..25f9040f7 --- /dev/null +++ b/fixtures/aisdk/anthropic/messages-stream-basic.json @@ -0,0 +1,166 @@ +{ + "case": "messages-stream-basic", + "model": { + "modelId": "claude-sonnet-4-5", + "provider": "anthropic.messages" + }, + "package": "@ai-sdk/anthropic", + "request": { + "body": { + "max_tokens": 64000, + "messages": [ + { + "content": [ + { + "text": "Say hello.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-sonnet-4-5", + "stream": true + }, + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "user-agent": "ai-sdk-anthropic/4.0.71 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-api-key": "" + }, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages" + }, + "response": { + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_fixture_2\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":1}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":2}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + "headers": { + "cache-control": "no-cache", + "content-type": "text/event-stream" + }, + "status": 200 + }, + "result": { + "finishReason": "stop", + "parts": [ + { + "type": "start" + }, + { + "request": {}, + "type": "start-step", + "warnings": [] + }, + { + "id": "0", + "type": "text-start" + }, + { + "id": "0", + "text": "Hello", + "type": "text-delta" + }, + { + "id": "0", + "text": " world", + "type": "text-delta" + }, + { + "id": "0", + "type": "text-end" + }, + { + "finishReason": "stop", + "performance": { + "effectiveOutputTokensPerSecond": 0, + "effectiveTotalTokensPerSecond": 0, + "inputTokensPerSecond": 0, + "outputTokensPerSecond": 0, + "responseTimeMs": 0, + "stepTimeMs": 0, + "timeBetweenOutputChunksMs": { + "avg": 0, + "max": 0, + "median": 0, + "min": 0, + "p10": 0, + "p90": 0 + }, + "timeToFirstOutputMs": 0, + "toolExecutionMs": {} + }, + "providerMetadata": { + "anthropic": { + "container": null, + "contextManagement": null, + "iterations": null, + "stopSequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 2 + } + } + }, + "rawFinishReason": "end_turn", + "response": { + "headers": { + "cache-control": "no-cache", + "content-type": "text/event-stream" + }, + "id": "msg_fixture_2", + "modelId": "claude-sonnet-4-5", + "timestamp": "2025-01-01T00:00:00.000Z" + }, + "type": "finish-step", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": {}, + "outputTokens": 2, + "raw": { + "input_tokens": 12, + "output_tokens": 2 + }, + "totalTokens": 14 + } + }, + { + "finishReason": "stop", + "rawFinishReason": "end_turn", + "totalUsage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": {}, + "outputTokens": 2, + "totalTokens": 14 + }, + "type": "finish" + } + ], + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": {}, + "outputTokens": 2, + "totalTokens": 14 + } + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "streamText" + }, + "version": "4.0.71" +} diff --git a/fixtures/aisdk/anthropic/messages-system-tools.json b/fixtures/aisdk/anthropic/messages-system-tools.json new file mode 100644 index 000000000..45e0b13d5 --- /dev/null +++ b/fixtures/aisdk/anthropic/messages-system-tools.json @@ -0,0 +1,166 @@ +{ + "case": "messages-system-tools", + "model": { + "modelId": "claude-sonnet-4-5", + "provider": "anthropic.messages" + }, + "package": "@ai-sdk/anthropic", + "request": { + "body": { + "max_tokens": 256, + "messages": [ + { + "content": [ + { + "text": "What is the weather in Paris?", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "claude-sonnet-4-5", + "system": [ + { + "text": "You are a weather assistant.", + "type": "text" + } + ], + "tool_choice": { + "type": "any" + }, + "tools": [ + { + "description": "Get the current weather for a city.", + "input_schema": { + "additionalProperties": false, + "properties": { + "city": { + "description": "City name", + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + }, + "name": "get_weather" + } + ] + }, + "headers": { + "anthropic-beta": "structured-outputs-2025-11-13", + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-api-key": "" + }, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages" + }, + "response": { + "body": { + "content": [ + { + "id": "toolu_fixture_1", + "input": { + "city": "Paris" + }, + "name": "get_weather", + "type": "tool_use" + } + ], + "id": "msg_fixture_1", + "model": "claude-sonnet-4-5", + "role": "assistant", + "stop_reason": "tool_use", + "stop_sequence": null, + "type": "message", + "usage": { + "input_tokens": 12, + "output_tokens": 9 + } + }, + "headers": { + "content-type": "application/json", + "request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "input": { + "city": "Paris" + }, + "toolCallId": "toolu_fixture_1", + "toolName": "get_weather", + "type": "tool-call" + } + ], + "finishReason": "tool-calls", + "providerMetadata": { + "anthropic": { + "container": null, + "contextManagement": null, + "iterations": null, + "stopSequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 9 + } + } + }, + "rawFinishReason": "tool_use", + "response": { + "headers": { + "content-type": "application/json", + "request-id": "req_fixture_1" + }, + "id": "msg_fixture_1", + "modelId": "claude-sonnet-4-5" + }, + "text": "", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": {}, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "maxOutputTokens": 256, + "prompt": "What is the weather in Paris?", + "system": "You are a weather assistant.", + "toolChoice": "required", + "tools": { + "get_weather": { + "description": "Get the current weather for a city.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "city": { + "description": "City name", + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + } + } + } + }, + "operation": "generateText" + }, + "version": "4.0.71" +} diff --git a/fixtures/aisdk/google/embedding-basic.json b/fixtures/aisdk/google/embedding-basic.json new file mode 100644 index 000000000..91658315e --- /dev/null +++ b/fixtures/aisdk/google/embedding-basic.json @@ -0,0 +1,94 @@ +{ + "case": "embedding-basic", + "model": { + "modelId": "gemini-embedding-001", + "provider": "google.generative-ai" + }, + "package": "@ai-sdk/google", + "request": { + "body": { + "requests": [ + { + "content": { + "parts": [ + { + "text": "sunny day at the beach" + } + ], + "role": "user" + }, + "model": "models/gemini-embedding-001" + }, + { + "content": { + "parts": [ + { + "text": "rainy day in the city" + } + ], + "role": "user" + }, + "model": "models/gemini-embedding-001" + } + ] + }, + "headers": { + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-goog-api-key": "" + }, + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents" + }, + "response": { + "body": { + "embeddings": [ + { + "values": [ + 0.125, + -0.25, + 0.5 + ] + }, + { + "values": [ + 0.0625, + 0.375, + -0.5 + ] + } + ] + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "embeddings": [ + [ + 0.125, + -0.25, + 0.5 + ], + [ + 0.0625, + 0.375, + -0.5 + ] + ], + "usage": { + "tokens": null + } + }, + "sdk": { + "input": { + "values": [ + "sunny day at the beach", + "rainy day in the city" + ] + }, + "operation": "embedMany" + }, + "version": "4.0.87" +} diff --git a/fixtures/aisdk/google/generate-basic.json b/fixtures/aisdk/google/generate-basic.json new file mode 100644 index 000000000..4a61501eb --- /dev/null +++ b/fixtures/aisdk/google/generate-basic.json @@ -0,0 +1,113 @@ +{ + "case": "generate-basic", + "model": { + "modelId": "gemini-2.5-flash", + "provider": "google.generative-ai" + }, + "package": "@ai-sdk/google", + "request": { + "body": { + "contents": [ + { + "parts": [ + { + "text": "Say hello." + } + ], + "role": "user" + } + ], + "generationConfig": {} + }, + "headers": { + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-goog-api-key": "" + }, + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" + }, + "response": { + "body": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Hello! How can I help you today?" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "modelVersion": "gemini-2.5-flash", + "responseId": "resp-fixture-1", + "usageMetadata": { + "candidatesTokenCount": 3, + "promptTokenCount": 4, + "totalTokenCount": 7 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 3, + "promptTokenCount": 4, + "totalTokenCount": 7 + } + } + }, + "rawFinishReason": "STOP", + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "resp-fixture-1", + "modelId": "gemini-2.5-flash" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 4 + }, + "inputTokens": 4, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 3 + }, + "outputTokens": 3, + "totalTokens": 7 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "4.0.87" +} diff --git a/fixtures/aisdk/google/generate-provider-options.json b/fixtures/aisdk/google/generate-provider-options.json new file mode 100644 index 000000000..0f6ce01da --- /dev/null +++ b/fixtures/aisdk/google/generate-provider-options.json @@ -0,0 +1,138 @@ +{ + "case": "generate-provider-options", + "model": { + "modelId": "gemini-2.5-flash", + "provider": "google.generative-ai" + }, + "package": "@ai-sdk/google", + "request": { + "body": { + "contents": [ + { + "parts": [ + { + "text": "Say hello." + } + ], + "role": "user" + } + ], + "generationConfig": { + "thinkingConfig": { + "includeThoughts": true, + "thinkingBudget": 1024 + } + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + } + ] + }, + "headers": { + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-goog-api-key": "" + }, + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" + }, + "response": { + "body": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Hello! How can I help you today?" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "modelVersion": "gemini-2.5-flash", + "responseId": "resp-fixture-1", + "usageMetadata": { + "candidatesTokenCount": 3, + "promptTokenCount": 4, + "totalTokenCount": 7 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 3, + "promptTokenCount": 4, + "totalTokenCount": 7 + } + } + }, + "rawFinishReason": "STOP", + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "resp-fixture-1", + "modelId": "gemini-2.5-flash" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 4 + }, + "inputTokens": 4, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 3 + }, + "outputTokens": 3, + "totalTokens": 7 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello.", + "providerOptions": { + "google": { + "safetySettings": [ + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "thinkingConfig": { + "includeThoughts": true, + "thinkingBudget": 1024 + } + } + } + }, + "operation": "generateText" + }, + "version": "4.0.87" +} diff --git a/fixtures/aisdk/google/generate-stream-basic.json b/fixtures/aisdk/google/generate-stream-basic.json new file mode 100644 index 000000000..cb0113eae --- /dev/null +++ b/fixtures/aisdk/google/generate-stream-basic.json @@ -0,0 +1,172 @@ +{ + "case": "generate-stream-basic", + "model": { + "modelId": "gemini-2.5-flash", + "provider": "google.generative-ai" + }, + "package": "@ai-sdk/google", + "request": { + "body": { + "contents": [ + { + "parts": [ + { + "text": "Say hello." + } + ], + "role": "user" + } + ], + "generationConfig": {} + }, + "headers": { + "content-type": "application/json", + "user-agent": "ai-sdk-google/4.0.87 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-goog-api-key": "" + }, + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + }, + "response": { + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hello\"}]},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"gemini-2.5-flash\",\"responseId\":\"resp-fixture-2\"}\n\ndata: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\" world\"}]},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":4,\"candidatesTokenCount\":2,\"totalTokenCount\":6},\"modelVersion\":\"gemini-2.5-flash\",\"responseId\":\"resp-fixture-2\"}\n\n", + "headers": { + "cache-control": "no-cache", + "content-type": "text/event-stream" + }, + "status": 200 + }, + "result": { + "finishReason": "stop", + "parts": [ + { + "type": "start" + }, + { + "request": {}, + "type": "start-step", + "warnings": [] + }, + { + "id": "0", + "type": "text-start" + }, + { + "id": "0", + "text": "Hello", + "type": "text-delta" + }, + { + "id": "0", + "text": " world", + "type": "text-delta" + }, + { + "id": "0", + "type": "text-end" + }, + { + "finishReason": "stop", + "performance": { + "effectiveOutputTokensPerSecond": 0, + "effectiveTotalTokensPerSecond": 0, + "inputTokensPerSecond": 0, + "outputTokensPerSecond": 0, + "responseTimeMs": 0, + "stepTimeMs": 0, + "timeBetweenOutputChunksMs": { + "avg": 0, + "max": 0, + "median": 0, + "min": 0, + "p10": 0, + "p90": 0 + }, + "timeToFirstOutputMs": 0, + "toolExecutionMs": {} + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 2, + "promptTokenCount": 4, + "totalTokenCount": 6 + } + } + }, + "rawFinishReason": "STOP", + "response": { + "headers": { + "cache-control": "no-cache", + "content-type": "text/event-stream" + }, + "id": "resp-fixture-2", + "modelId": "gemini-2.5-flash", + "timestamp": "2025-01-01T00:00:00.000Z" + }, + "type": "finish-step", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 4 + }, + "inputTokens": 4, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 2 + }, + "outputTokens": 2, + "raw": { + "candidatesTokenCount": 2, + "promptTokenCount": 4, + "totalTokenCount": 6 + }, + "totalTokens": 6 + } + }, + { + "finishReason": "stop", + "rawFinishReason": "STOP", + "totalUsage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 4 + }, + "inputTokens": 4, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 2 + }, + "outputTokens": 2, + "totalTokens": 6 + }, + "type": "finish" + } + ], + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 4 + }, + "inputTokens": 4, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 2 + }, + "outputTokens": 2, + "totalTokens": 6 + } + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "streamText" + }, + "version": "4.0.87" +} diff --git a/fixtures/aisdk/google/generate-tools.json b/fixtures/aisdk/google/generate-tools.json new file mode 100644 index 000000000..a00a5a0dd --- /dev/null +++ b/fixtures/aisdk/google/generate-tools.json @@ -0,0 +1,169 @@ +{ + "case": "generate-tools", + "model": { + "modelId": "gemini-2.5-flash", + "provider": "google.generative-ai" + }, + "package": "@ai-sdk/google", + "request": { + "body": { + "contents": [ + { + "parts": [ + { + "text": "What is the weather in Paris?" + } + ], + "role": "user" + } + ], + "generationConfig": {}, + "toolConfig": { + "functionCallingConfig": { + "mode": "ANY" + } + }, + "tools": [ + { + "functionDeclarations": [ + { + "description": "Get the current weather for a city.", + "name": "get_weather", + "parametersJsonSchema": { + "additionalProperties": false, + "properties": { + "city": { + "description": "City name", + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + } + } + ] + } + ] + }, + "headers": { + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-goog-api-key": "" + }, + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" + }, + "response": { + "body": { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "args": { + "city": "Paris" + }, + "name": "get_weather" + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "modelVersion": "gemini-2.5-flash", + "responseId": "resp-fixture-1", + "usageMetadata": { + "candidatesTokenCount": 3, + "promptTokenCount": 4, + "totalTokenCount": 7 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "input": { + "city": "Paris" + }, + "toolCallId": "5WtKk8A3IuO3vm8Q", + "toolName": "get_weather", + "type": "tool-call" + } + ], + "finishReason": "tool-calls", + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 3, + "promptTokenCount": 4, + "totalTokenCount": 7 + } + } + }, + "rawFinishReason": "STOP", + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "resp-fixture-1", + "modelId": "gemini-2.5-flash" + }, + "text": "", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 4 + }, + "inputTokens": 4, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 3 + }, + "outputTokens": 3, + "totalTokens": 7 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "What is the weather in Paris?", + "toolChoice": "required", + "tools": { + "get_weather": { + "description": "Get the current weather for a city.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "city": { + "description": "City name", + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + } + } + } + }, + "operation": "generateText" + }, + "version": "4.0.87" +} diff --git a/fixtures/aisdk/openai-compatible/chat-basic.json b/fixtures/aisdk/openai-compatible/chat-basic.json new file mode 100644 index 000000000..7e4abaf00 --- /dev/null +++ b/fixtures/aisdk/openai-compatible/chat-basic.json @@ -0,0 +1,95 @@ +{ + "case": "chat-basic", + "model": { + "modelId": "llama-3.3-70b-versatile", + "provider": "groq.chat" + }, + "package": "@ai-sdk/openai-compatible", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "llama-3.3-70b-versatile" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "llama-3.3-70b-versatile", + "object": "chat.completion", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 12, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "groq": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "chatcmpl-fixture-1", + "modelId": "llama-3.3-70b-versatile" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "3.0.62" +} diff --git a/fixtures/aisdk/openai-compatible/chat-no-api-key.json b/fixtures/aisdk/openai-compatible/chat-no-api-key.json new file mode 100644 index 000000000..d747d9337 --- /dev/null +++ b/fixtures/aisdk/openai-compatible/chat-no-api-key.json @@ -0,0 +1,97 @@ +{ + "case": "chat-no-api-key", + "model": { + "modelId": "local-model", + "provider": "local.chat" + }, + "observations": { + "hasAuthorizationHeader": false + }, + "package": "@ai-sdk/openai-compatible", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "local-model" + }, + "headers": { + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "http://localhost:1234/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "llama-3.3-70b-versatile", + "object": "chat.completion", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 12, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "local": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "chatcmpl-fixture-1", + "modelId": "llama-3.3-70b-versatile" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "3.0.62" +} diff --git a/fixtures/aisdk/openai-compatible/chat-provider-options-generic-key-unknown.json b/fixtures/aisdk/openai-compatible/chat-provider-options-generic-key-unknown.json new file mode 100644 index 000000000..63aa1513c --- /dev/null +++ b/fixtures/aisdk/openai-compatible/chat-provider-options-generic-key-unknown.json @@ -0,0 +1,105 @@ +{ + "case": "chat-provider-options-generic-key-unknown", + "model": { + "modelId": "llama-3.3-70b-versatile", + "provider": "groq.chat" + }, + "observations": { + "bodyHasExtraField": false + }, + "package": "@ai-sdk/openai-compatible", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "llama-3.3-70b-versatile", + "user": "u1" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "llama-3.3-70b-versatile", + "object": "chat.completion", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 12, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "groq": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "chatcmpl-fixture-1", + "modelId": "llama-3.3-70b-versatile" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello.", + "providerOptions": { + "openaiCompatible": { + "extra_field": "x", + "user": "u1" + } + } + }, + "operation": "generateText" + }, + "version": "3.0.62" +} diff --git a/fixtures/aisdk/openai-compatible/chat-provider-options-namespace.json b/fixtures/aisdk/openai-compatible/chat-provider-options-namespace.json new file mode 100644 index 000000000..565908a7a --- /dev/null +++ b/fixtures/aisdk/openai-compatible/chat-provider-options-namespace.json @@ -0,0 +1,110 @@ +{ + "case": "chat-provider-options-namespace", + "model": { + "modelId": "llama-3.3-70b-versatile", + "provider": "groq.chat" + }, + "observations": { + "bodyHasFooAtTopLevel": true, + "bodyUser": "u1", + "providerIsGroqChat": true + }, + "package": "@ai-sdk/openai-compatible", + "request": { + "body": { + "foo": "bar", + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "llama-3.3-70b-versatile", + "user": "u1" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "llama-3.3-70b-versatile", + "object": "chat.completion", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 12, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "groq": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "chatcmpl-fixture-1", + "modelId": "llama-3.3-70b-versatile" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello.", + "providerOptions": { + "groq": { + "foo": "bar" + }, + "openaiCompatible": { + "user": "u1" + } + } + }, + "operation": "generateText" + }, + "version": "3.0.62" +} diff --git a/fixtures/aisdk/openai-compatible/chat-query-params-and-headers.json b/fixtures/aisdk/openai-compatible/chat-query-params-and-headers.json new file mode 100644 index 000000000..3b2707b21 --- /dev/null +++ b/fixtures/aisdk/openai-compatible/chat-query-params-and-headers.json @@ -0,0 +1,109 @@ +{ + "case": "chat-query-params-and-headers", + "model": { + "modelId": "llama-3.3-70b-versatile", + "provider": "groq.chat" + }, + "observations": { + "headerNamesAsSent": [ + "x-a", + "x-b", + "x-c" + ] + }, + "package": "@ai-sdk/openai-compatible", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "llama-3.3-70b-versatile" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-a": "1", + "x-b": "3", + "x-c": "4" + }, + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions?api-version=1" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "llama-3.3-70b-versatile", + "object": "chat.completion", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 12, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "groq": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "chatcmpl-fixture-1", + "modelId": "llama-3.3-70b-versatile" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "headers": { + "x-b": "3", + "x-c": "4" + }, + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "3.0.62" +} diff --git a/fixtures/aisdk/openai-compatible/chat-stream-basic.json b/fixtures/aisdk/openai-compatible/chat-stream-basic.json new file mode 100644 index 000000000..9973beed4 --- /dev/null +++ b/fixtures/aisdk/openai-compatible/chat-stream-basic.json @@ -0,0 +1,157 @@ +{ + "case": "chat-stream-basic", + "model": { + "modelId": "llama-3.3-70b-versatile", + "provider": "groq.chat" + }, + "package": "@ai-sdk/openai-compatible", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "llama-3.3-70b-versatile", + "stream": true + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai-sdk-openai-compatible/3.0.62 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions" + }, + "response": { + "body": "data: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"llama-3.3-70b-versatile\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"llama-3.3-70b-versatile\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"llama-3.3-70b-versatile\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"llama-3.3-70b-versatile\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"llama-3.3-70b-versatile\",\"choices\":[],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":2,\"total_tokens\":14}}\n\ndata: [DONE]\n\n", + "headers": { + "cache-control": "no-cache", + "content-type": "text/event-stream" + }, + "status": 200 + }, + "result": { + "finishReason": "stop", + "parts": [ + { + "type": "start" + }, + { + "request": {}, + "type": "start-step", + "warnings": [] + }, + { + "id": "txt-0", + "type": "text-start" + }, + { + "id": "txt-0", + "text": "Hello", + "type": "text-delta" + }, + { + "id": "txt-0", + "text": " world", + "type": "text-delta" + }, + { + "id": "txt-0", + "type": "text-end" + }, + { + "finishReason": "stop", + "performance": { + "effectiveOutputTokensPerSecond": 0, + "effectiveTotalTokensPerSecond": 0, + "inputTokensPerSecond": 0, + "outputTokensPerSecond": 0, + "responseTimeMs": 0, + "stepTimeMs": 0, + "timeBetweenOutputChunksMs": { + "avg": 0, + "max": 0, + "median": 0, + "min": 0, + "p10": 0, + "p90": 0 + }, + "timeToFirstOutputMs": 0, + "toolExecutionMs": {} + }, + "providerMetadata": { + "groq": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "cache-control": "no-cache", + "content-type": "text/event-stream" + }, + "id": "chatcmpl-fixture-2", + "modelId": "llama-3.3-70b-versatile", + "timestamp": "2025-01-01T00:00:00.000Z" + }, + "type": "finish-step", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 2 + }, + "outputTokens": 2, + "raw": { + "completion_tokens": 2, + "prompt_tokens": 12, + "total_tokens": 14 + }, + "totalTokens": 14 + } + }, + { + "finishReason": "stop", + "rawFinishReason": "stop", + "totalUsage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 2 + }, + "outputTokens": 2, + "totalTokens": 14 + }, + "type": "finish" + } + ], + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 2 + }, + "outputTokens": 2, + "totalTokens": 14 + } + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "streamText" + }, + "version": "3.0.62" +} diff --git a/fixtures/aisdk/openai-compatible/embedding-basic.json b/fixtures/aisdk/openai-compatible/embedding-basic.json new file mode 100644 index 000000000..9c54480c4 --- /dev/null +++ b/fixtures/aisdk/openai-compatible/embedding-basic.json @@ -0,0 +1,86 @@ +{ + "case": "embedding-basic", + "model": { + "modelId": "text-embedding-fixture", + "provider": "groq.embedding" + }, + "package": "@ai-sdk/openai-compatible", + "request": { + "body": { + "encoding_format": "float", + "input": [ + "sunny day at the beach", + "rainy day in the city" + ], + "model": "text-embedding-fixture" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.groq.com/openai/v1/embeddings" + }, + "response": { + "body": { + "data": [ + { + "embedding": [ + 0.125, + -0.25, + 0.5 + ], + "index": 0, + "object": "embedding" + }, + { + "embedding": [ + 0.0625, + 0.375, + -0.5 + ], + "index": 1, + "object": "embedding" + } + ], + "model": "text-embedding-fixture", + "object": "list", + "usage": { + "prompt_tokens": 10, + "total_tokens": 10 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "embeddings": [ + [ + 0.125, + -0.25, + 0.5 + ], + [ + 0.0625, + 0.375, + -0.5 + ] + ], + "usage": { + "tokens": 10 + } + }, + "sdk": { + "input": { + "values": [ + "sunny day at the beach", + "rainy day in the city" + ] + }, + "operation": "embedMany" + }, + "version": "3.0.62" +} diff --git a/fixtures/aisdk/openai/chat-basic.json b/fixtures/aisdk/openai/chat-basic.json new file mode 100644 index 000000000..bea6c44fe --- /dev/null +++ b/fixtures/aisdk/openai/chat-basic.json @@ -0,0 +1,104 @@ +{ + "case": "chat-basic", + "model": { + "modelId": "gpt-4o", + "provider": "openai.chat" + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "gpt-4o" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "system_fingerprint": "fp_fixture", + "usage": { + "completion_tokens": 9, + "completion_tokens_details": { + "reasoning_tokens": 0 + }, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "openai": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "id": "chatcmpl-fixture-1", + "modelId": "gpt-4o-2024-08-06" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/chat-custom-name.json b/fixtures/aisdk/openai/chat-custom-name.json new file mode 100644 index 000000000..f1fea9733 --- /dev/null +++ b/fixtures/aisdk/openai/chat-custom-name.json @@ -0,0 +1,107 @@ +{ + "case": "chat-custom-name", + "model": { + "modelId": "gpt-4o", + "provider": "proxy.chat" + }, + "observations": { + "providerIsProxyChat": true + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "gpt-4o" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "system_fingerprint": "fp_fixture", + "usage": { + "completion_tokens": 9, + "completion_tokens_details": { + "reasoning_tokens": 0 + }, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "openai": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "id": "chatcmpl-fixture-1", + "modelId": "gpt-4o-2024-08-06" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/chat-headers-merge.json b/fixtures/aisdk/openai/chat-headers-merge.json new file mode 100644 index 000000000..d24d734f6 --- /dev/null +++ b/fixtures/aisdk/openai/chat-headers-merge.json @@ -0,0 +1,119 @@ +{ + "case": "chat-headers-merge", + "model": { + "modelId": "gpt-4o", + "provider": "openai.chat" + }, + "observations": { + "headerNamesAsSent": [ + "x-a", + "x-b", + "x-c" + ], + "xB": "3" + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "gpt-4o" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22", + "x-a": "1", + "x-b": "3", + "x-c": "4" + }, + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "system_fingerprint": "fp_fixture", + "usage": { + "completion_tokens": 9, + "completion_tokens_details": { + "reasoning_tokens": 0 + }, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "openai": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "id": "chatcmpl-fixture-1", + "modelId": "gpt-4o-2024-08-06" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "headers": { + "x-b": "3", + "x-c": "4" + }, + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/chat-provider-options.json b/fixtures/aisdk/openai/chat-provider-options.json new file mode 100644 index 000000000..719052387 --- /dev/null +++ b/fixtures/aisdk/openai/chat-provider-options.json @@ -0,0 +1,153 @@ +{ + "case": "chat-provider-options", + "model": { + "modelId": "gpt-4o", + "provider": "openai.chat" + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "messages": [ + { + "content": "What is the weather in Paris?", + "role": "user" + } + ], + "model": "gpt-4o", + "parallel_tool_calls": false, + "tool_choice": "auto", + "tools": [ + { + "function": { + "description": "Get the current weather for a city.", + "name": "get_weather", + "parameters": { + "additionalProperties": false, + "properties": { + "city": { + "description": "City name", + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + } + }, + "type": "function" + } + ], + "user": "u1" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "system_fingerprint": "fp_fixture", + "usage": { + "completion_tokens": 9, + "completion_tokens_details": { + "reasoning_tokens": 0 + }, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "openai": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "id": "chatcmpl-fixture-1", + "modelId": "gpt-4o-2024-08-06" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "What is the weather in Paris?", + "providerOptions": { + "openai": { + "parallelToolCalls": false, + "user": "u1" + } + }, + "tools": { + "get_weather": { + "description": "Get the current weather for a city.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "city": { + "description": "City name", + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + } + } + } + }, + "operation": "generateText" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/chat-stream-basic.json b/fixtures/aisdk/openai/chat-stream-basic.json new file mode 100644 index 000000000..2c927f6e3 --- /dev/null +++ b/fixtures/aisdk/openai/chat-stream-basic.json @@ -0,0 +1,160 @@ +{ + "case": "chat-stream-basic", + "model": { + "modelId": "gpt-4o", + "provider": "openai.chat" + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "gpt-4o", + "stream": true, + "stream_options": { + "include_usage": true + } + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai-sdk-openai/4.0.83 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions" + }, + "response": { + "body": "data: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"gpt-4o-2024-08-06\",\"system_fingerprint\":\"fp_fixture\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"gpt-4o-2024-08-06\",\"system_fingerprint\":\"fp_fixture\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"gpt-4o-2024-08-06\",\"system_fingerprint\":\"fp_fixture\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"gpt-4o-2024-08-06\",\"system_fingerprint\":\"fp_fixture\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"chatcmpl-fixture-2\",\"object\":\"chat.completion.chunk\",\"created\":1735689600,\"model\":\"gpt-4o-2024-08-06\",\"choices\":[],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":2,\"total_tokens\":14}}\n\ndata: [DONE]\n\n", + "headers": { + "cache-control": "no-cache", + "content-type": "text/event-stream" + }, + "status": 200 + }, + "result": { + "finishReason": "stop", + "parts": [ + { + "type": "start" + }, + { + "request": {}, + "type": "start-step", + "warnings": [] + }, + { + "id": "0", + "type": "text-start" + }, + { + "id": "0", + "text": "Hello", + "type": "text-delta" + }, + { + "id": "0", + "text": " world", + "type": "text-delta" + }, + { + "id": "0", + "type": "text-end" + }, + { + "finishReason": "stop", + "performance": { + "effectiveOutputTokensPerSecond": 0, + "effectiveTotalTokensPerSecond": 0, + "inputTokensPerSecond": 0, + "outputTokensPerSecond": 0, + "responseTimeMs": 0, + "stepTimeMs": 0, + "timeBetweenOutputChunksMs": { + "avg": 0, + "max": 0, + "median": 0, + "min": 0, + "p10": 0, + "p90": 0 + }, + "timeToFirstOutputMs": 0, + "toolExecutionMs": {} + }, + "providerMetadata": { + "openai": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "cache-control": "no-cache", + "content-type": "text/event-stream" + }, + "id": "chatcmpl-fixture-2", + "modelId": "gpt-4o-2024-08-06", + "timestamp": "2025-01-01T00:00:00.000Z" + }, + "type": "finish-step", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 2 + }, + "outputTokens": 2, + "raw": { + "completion_tokens": 2, + "prompt_tokens": 12, + "total_tokens": 14 + }, + "totalTokens": 14 + } + }, + { + "finishReason": "stop", + "rawFinishReason": "stop", + "totalUsage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 2 + }, + "outputTokens": 2, + "totalTokens": 14 + }, + "type": "finish" + } + ], + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 2 + }, + "outputTokens": 2, + "totalTokens": 14 + } + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "streamText" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/chat-system-and-settings.json b/fixtures/aisdk/openai/chat-system-and-settings.json new file mode 100644 index 000000000..1031365da --- /dev/null +++ b/fixtures/aisdk/openai/chat-system-and-settings.json @@ -0,0 +1,130 @@ +{ + "case": "chat-system-and-settings", + "model": { + "modelId": "gpt-4o", + "provider": "openai.chat" + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "max_tokens": 64, + "messages": [ + { + "content": "You are a terse assistant.", + "role": "system" + }, + { + "content": "Name a color.", + "role": "user" + } + ], + "model": "gpt-4o", + "seed": 42, + "stop": [ + "END", + "###" + ], + "temperature": 0.3, + "top_p": 0.9 + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Blue.", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "system_fingerprint": "fp_fixture", + "usage": { + "completion_tokens": 9, + "completion_tokens_details": { + "reasoning_tokens": 0 + }, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Blue.", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "openai": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "id": "chatcmpl-fixture-1", + "modelId": "gpt-4o-2024-08-06" + }, + "text": "Blue.", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "maxOutputTokens": 64, + "messages": [ + { + "content": "Name a color.", + "role": "user" + } + ], + "seed": 42, + "stopSequences": [ + "END", + "###" + ], + "system": "You are a terse assistant.", + "temperature": 0.3, + "topP": 0.9 + }, + "operation": "generateText" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/chat-tools.json b/fixtures/aisdk/openai/chat-tools.json new file mode 100644 index 000000000..9a4c0cd4a --- /dev/null +++ b/fixtures/aisdk/openai/chat-tools.json @@ -0,0 +1,158 @@ +{ + "case": "chat-tools", + "model": { + "modelId": "gpt-4o", + "provider": "openai.chat" + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "messages": [ + { + "content": "What is the weather in Paris?", + "role": "user" + } + ], + "model": "gpt-4o", + "tool_choice": "required", + "tools": [ + { + "function": { + "description": "Get the current weather for a city.", + "name": "get_weather", + "parameters": { + "additionalProperties": false, + "properties": { + "city": { + "description": "City name", + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + } + }, + "type": "function" + } + ] + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "content": null, + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":\"Paris\"}", + "name": "get_weather" + }, + "id": "call_fixture_1", + "type": "function" + } + ] + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "system_fingerprint": "fp_fixture", + "usage": { + "completion_tokens": 9, + "completion_tokens_details": { + "reasoning_tokens": 0 + }, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "input": { + "city": "Paris" + }, + "toolCallId": "call_fixture_1", + "toolName": "get_weather", + "type": "tool-call" + } + ], + "finishReason": "tool-calls", + "providerMetadata": { + "openai": {} + }, + "rawFinishReason": "tool_calls", + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "chatcmpl-fixture-1", + "modelId": "gpt-4o-2024-08-06" + }, + "text": "", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "What is the weather in Paris?", + "toolChoice": "required", + "tools": { + "get_weather": { + "description": "Get the current weather for a city.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "city": { + "description": "City name", + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + } + } + } + }, + "operation": "generateText" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/embedding-basic.json b/fixtures/aisdk/openai/embedding-basic.json new file mode 100644 index 000000000..344b54a68 --- /dev/null +++ b/fixtures/aisdk/openai/embedding-basic.json @@ -0,0 +1,86 @@ +{ + "case": "embedding-basic", + "model": { + "modelId": "text-embedding-3-small", + "provider": "openai.embedding" + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "encoding_format": "float", + "input": [ + "sunny day at the beach", + "rainy day in the city" + ], + "model": "text-embedding-3-small" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.openai.com/v1/embeddings" + }, + "response": { + "body": { + "data": [ + { + "embedding": [ + 0.125, + -0.25, + 0.5 + ], + "index": 0, + "object": "embedding" + }, + { + "embedding": [ + 0.0625, + 0.375, + -0.5 + ], + "index": 1, + "object": "embedding" + } + ], + "model": "text-embedding-3-small", + "object": "list", + "usage": { + "prompt_tokens": 10, + "total_tokens": 10 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "embeddings": [ + [ + 0.125, + -0.25, + 0.5 + ], + [ + 0.0625, + 0.375, + -0.5 + ] + ], + "usage": { + "tokens": 10 + } + }, + "sdk": { + "input": { + "values": [ + "sunny day at the beach", + "rainy day in the city" + ] + }, + "operation": "embedMany" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/empty-api-key.json b/fixtures/aisdk/openai/empty-api-key.json new file mode 100644 index 000000000..0dac40642 --- /dev/null +++ b/fixtures/aisdk/openai/empty-api-key.json @@ -0,0 +1,109 @@ +{ + "case": "empty-api-key", + "model": { + "modelId": "gpt-4o", + "provider": "openai.chat" + }, + "observations": { + "authorizationIsEmptyBearer": true, + "authorizationRawLength": 6, + "usedEnvKey": false + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "messages": [ + { + "content": "Say hello.", + "role": "user" + } + ], + "model": "gpt-4o" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + } + } + ], + "created": 1735689600, + "id": "chatcmpl-fixture-1", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "system_fingerprint": "fp_fixture", + "usage": { + "completion_tokens": 9, + "completion_tokens_details": { + "reasoning_tokens": 0 + }, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "status": 200 + }, + "result": { + "content": [ + { + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "openai": {} + }, + "rawFinishReason": "stop", + "response": { + "headers": { + "content-type": "application/json", + "x-request-id": "req_fixture_1" + }, + "id": "chatcmpl-fixture-1", + "modelId": "gpt-4o-2024-08-06" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/missing-api-key.json b/fixtures/aisdk/openai/missing-api-key.json new file mode 100644 index 000000000..b4b9dcece --- /dev/null +++ b/fixtures/aisdk/openai/missing-api-key.json @@ -0,0 +1,24 @@ +{ + "case": "missing-api-key", + "error": { + "message": "OpenAI API key is missing. Pass it using the 'apiKey' parameter or the OPENAI_API_KEY environment variable.", + "name": "AI_LoadAPIKeyError" + }, + "model": { + "modelId": "gpt-4o", + "provider": "openai.chat" + }, + "observations": { + "requestsSent": 0 + }, + "package": "@ai-sdk/openai", + "request": null, + "response": null, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "4.0.83" +} diff --git a/fixtures/aisdk/openai/responses-basic.json b/fixtures/aisdk/openai/responses-basic.json new file mode 100644 index 000000000..242183ce7 --- /dev/null +++ b/fixtures/aisdk/openai/responses-basic.json @@ -0,0 +1,120 @@ +{ + "case": "responses-basic", + "model": { + "modelId": "gpt-4o", + "provider": "openai.responses" + }, + "package": "@ai-sdk/openai", + "request": { + "body": { + "input": [ + { + "content": [ + { + "text": "Say hello.", + "type": "input_text" + } + ], + "role": "user" + } + ], + "model": "gpt-4o" + }, + "headers": { + "authorization": "", + "content-type": "application/json", + "user-agent": "ai/7.0.127 ai-sdk-provider-utils/5.0.53 node.js/22" + }, + "method": "POST", + "url": "https://api.openai.com/v1/responses" + }, + "response": { + "body": { + "created_at": 1735689600, + "error": null, + "id": "resp_fixture_1", + "incomplete_details": null, + "model": "gpt-4o-2024-08-06", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "Hello! How can I help you today?", + "type": "output_text" + } + ], + "id": "msg_fixture_1", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "status": "completed", + "usage": { + "input_tokens": 12, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 9, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 21 + } + }, + "headers": { + "content-type": "application/json" + }, + "status": 200 + }, + "result": { + "content": [ + { + "providerMetadata": { + "openai": { + "itemId": "msg_fixture_1" + } + }, + "text": "Hello! How can I help you today?", + "type": "text" + } + ], + "finishReason": "stop", + "providerMetadata": { + "openai": { + "responseId": "resp_fixture_1" + } + }, + "response": { + "headers": { + "content-type": "application/json" + }, + "id": "resp_fixture_1", + "modelId": "gpt-4o-2024-08-06" + }, + "text": "Hello! How can I help you today?", + "usage": { + "inputTokenDetails": { + "cacheReadTokens": 0, + "noCacheTokens": 12 + }, + "inputTokens": 12, + "outputTokenDetails": { + "reasoningTokens": 0, + "textTokens": 9 + }, + "outputTokens": 9, + "totalTokens": 21 + }, + "warnings": [] + }, + "sdk": { + "input": { + "prompt": "Say hello." + }, + "operation": "generateText" + }, + "version": "4.0.83" +} diff --git a/scripts/aisdk-fixtures/README.md b/scripts/aisdk-fixtures/README.md new file mode 100644 index 000000000..15bb3adc1 --- /dev/null +++ b/scripts/aisdk-fixtures/README.md @@ -0,0 +1,61 @@ +# AI SDK protocol fixtures + +Records what the official Vercel AI SDK (`ai` + `@ai-sdk/*`, versions pinned in +`package.json` and `fixtures/aisdk/VERSIONS.json`) sends and returns for a fixed input over +a mocked transport. These are the AI SDK half of the RFC-0036 section 9.3 protocol +differential: Rust tests replay the same inputs against aimux's providers and compare URL, +headers, body, normalized result, stream parts and error classification. + +## Run + +```sh +cd scripts/aisdk-fixtures +npm ci +node record.mjs # all cases +node record.mjs openai # one package +node record.mjs openai/chat-basic # one case (VERSIONS.json / SKIPPED.md untouched) +``` + +No network is used. The mock `fetch` (`mock-fetch.mjs`) throws on any URL other than the +case's expected one and on a second request. + +## Layout + +- `record.mjs` - runner. `mock-fetch.mjs` - `createMockFetch(canned)`. `common.mjs` - fake keys, SSE helper. +- `cases/.mjs` - default-exports an array of cases: + `{ name, build(mock), call(model, run), response, env?, expectError?, expectRequests?, observe?, skip? }`. + `build` creates the provider/model with `fetch: mock`; `call` invokes `run('generateText' | 'streamText' | 'embed' | 'embedMany' | 'generateImage', input)`; + `response` is `{ url, status, headers, body }` (JSON object, or a string with `content-type: text/event-stream` for SSE). + `observe` adds an `observations` object (e.g. which header names were sent, `usedEnvKey`). + `skip: ''` skips the case and records it in `fixtures/aisdk/SKIPPED.md`. +- Output: `fixtures/aisdk//.json`, where `` is `openai`, `openai-compatible`, `anthropic`, `google`. + +## What a fixture captures + +`package`, `version`, `case`; `sdk.{operation,input}` (the options passed to the SDK, minus the model; tools +are serialized as `{ description, inputSchema }`); `model.{provider,modelId}`; `request.{method,url,headers,body}` +(headers lower-cased and sorted, JSON body parsed); `response.{status,headers,body}` (the canned reply); +and either `result` (normalized SDK output) or `error.{name,message}` (then `request` is `null` if nothing was sent); +optionally `observations`. All object keys are sorted so diffs are stable. + +`result` per operation: `generateText` -> `content, finishReason, rawFinishReason, providerMetadata, response{id,modelId,headers}, text, usage, warnings`; +`streamText` -> `parts` (full `fullStream`), `finishReason`, `usage`; `embed`/`embedMany` -> `embeddings, usage, providerMetadata`; +`generateImage` -> `images[{base64Length, mediaType}], warnings`. + +## Redaction and determinism + +- Headers named `authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, or whose value contains one of the fake keys, + become `""` (name kept). Only fake keys are used (`sk-test-fixture`, `sk-env-should-not-be-used`); + the recorder aborts if either string appears anywhere in a fixture. +- `Date` is frozen (2025-01-01T00:00:00Z), `Math.random` is seeded, and `_internal.generateId/generateCallId/now` + are injected into `generateText`/`streamText` (these `_internal` options are not recorded in `sdk.input`). +- `navigator.userAgent` is pinned to `Node.js/22` so the `user-agent` header (`ai/ ai-sdk-provider-utils/ node.js/22`) + does not depend on the local Node version. The user-agent string is otherwise kept verbatim. +- Provider env vars (`OPENAI_*`, `ANTHROPIC_*`, `GOOGLE_*`, `GROQ_*`, `AI_GATEWAY_*`, `VERCEL_*`) are cleared per case; a case sets what it needs via `env`. +- Canned responses always carry explicit ids, so provider-side generated ids never enter the fixtures. + +## Policy + +Fixtures are committed and are the reference that Rust tests replay. Regenerate them only on purpose: when bumping the +pinned versions (update `package.json` + `package-lock.json` together) or adding/changing a case. Review the diff; a changed +fixture means the SDK's wire behavior changed. Running `node record.mjs` twice must yield no diff. diff --git a/scripts/aisdk-fixtures/cases/anthropic.mjs b/scripts/aisdk-fixtures/cases/anthropic.mjs new file mode 100644 index 000000000..bf73ebfb4 --- /dev/null +++ b/scripts/aisdk-fixtures/cases/anthropic.mjs @@ -0,0 +1,111 @@ +import { jsonSchema, tool } from 'ai'; +import { createAnthropic } from '@ai-sdk/anthropic'; +import { API_KEY, JSON_HEADERS, SSE_HEADERS, sse } from '../common.mjs'; + +const URL_ = 'https://api.anthropic.com/v1/messages'; +const MODEL = 'claude-sonnet-4-5'; +const provider = (mock, extra = {}) => createAnthropic({ apiKey: API_KEY, fetch: mock, ...extra }); + +const message = (content, stop_reason = 'end_turn') => ({ + id: 'msg_fixture_1', + type: 'message', + role: 'assistant', + model: MODEL, + content, + stop_reason, + stop_sequence: null, + usage: { input_tokens: 12, output_tokens: 9 }, +}); + +const ok = (content = [{ type: 'text', text: 'Hello! How can I help you today?' }], stop_reason) => ({ + url: URL_, + status: 200, + headers: { ...JSON_HEADERS, 'request-id': 'req_fixture_1' }, + body: message(content, stop_reason), +}); + +const weatherTool = tool({ + description: 'Get the current weather for a city.', + inputSchema: jsonSchema({ + type: 'object', + properties: { city: { type: 'string', description: 'City name' } }, + required: ['city'], + additionalProperties: false, + }), +}); + +export default [ + { + name: 'messages-basic', + build: (mock) => provider(mock)(MODEL), + call: (model, run) => run('generateText', { prompt: 'Say hello.' }), + response: ok(), + }, + { + name: 'messages-system-tools', + build: (mock) => provider(mock)(MODEL), + call: (model, run) => + run('generateText', { + system: 'You are a weather assistant.', + prompt: 'What is the weather in Paris?', + tools: { get_weather: weatherTool }, + toolChoice: 'required', + maxOutputTokens: 256, + }), + response: ok([{ type: 'tool_use', id: 'toolu_fixture_1', name: 'get_weather', input: { city: 'Paris' } }], 'tool_use'), + }, + { + name: 'messages-stream-basic', + build: (mock) => provider(mock)(MODEL), + call: (model, run) => run('streamText', { prompt: 'Say hello.' }), + response: { + url: URL_, + status: 200, + headers: SSE_HEADERS, + body: sse([ + { event: 'message_start', data: { type: 'message_start', message: { id: 'msg_fixture_2', type: 'message', role: 'assistant', model: MODEL, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 12, output_tokens: 1 } } } }, + { event: 'content_block_start', data: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } }, + { event: 'ping', data: { type: 'ping' } }, + { event: 'content_block_delta', data: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } } }, + { event: 'content_block_delta', data: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ' world' } } }, + { event: 'content_block_stop', data: { type: 'content_block_stop', index: 0 } }, + { event: 'message_delta', data: { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 2 } } }, + { event: 'message_stop', data: { type: 'message_stop' } }, + ]), + }, + }, + { + // Documented option: extended thinking via providerOptions.anthropic.thinking. + name: 'messages-provider-options', + build: (mock) => provider(mock)(MODEL), + call: (model, run) => + run('generateText', { + prompt: 'What is 17 * 23?', + maxOutputTokens: 4096, + providerOptions: { anthropic: { thinking: { type: 'enabled', budgetTokens: 2000 } } }, + }), + response: ok([ + { type: 'thinking', thinking: '17 * 23 = 391.', signature: 'sig_fixture' }, + { type: 'text', text: '391' }, + ]), + }, + { + // Which key wins when the same option is given under `anthropic` and under the custom provider name? + name: 'messages-custom-name', + build: (mock) => provider(mock, { name: 'myproxy' })(MODEL), + call: (model, run) => + run('generateText', { + prompt: 'Say hello.', + providerOptions: { + anthropic: { metadata: { userId: 'from-anthropic-key' } }, + myproxy: { metadata: { userId: 'from-myproxy-key' } }, + }, + }), + response: ok(), + observe: ({ model, request, result }) => ({ + bodyMetadataUserId: request.body.metadata?.user_id, + modelProvider: model.provider, + providerMetadataKeys: Object.keys(result.providerMetadata ?? {}).sort(), + }), + }, +]; diff --git a/scripts/aisdk-fixtures/cases/google.mjs b/scripts/aisdk-fixtures/cases/google.mjs new file mode 100644 index 000000000..b2be8e920 --- /dev/null +++ b/scripts/aisdk-fixtures/cases/google.mjs @@ -0,0 +1,87 @@ +import { jsonSchema, tool } from 'ai'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { API_KEY, JSON_HEADERS, SSE_HEADERS, sse } from '../common.mjs'; + +const BASE = 'https://generativelanguage.googleapis.com/v1beta'; +const MODEL = 'gemini-2.5-flash'; +const provider = (mock, extra = {}) => createGoogleGenerativeAI({ apiKey: API_KEY, fetch: mock, ...extra }); + +const candidates = (parts, finishReason = 'STOP') => [{ content: { role: 'model', parts }, finishReason, index: 0 }]; +const usageMetadata = { promptTokenCount: 4, candidatesTokenCount: 3, totalTokenCount: 7 }; + +const ok = (parts = [{ text: 'Hello! How can I help you today?' }]) => ({ + url: `${BASE}/models/${MODEL}:generateContent`, + status: 200, + headers: JSON_HEADERS, + body: { candidates: candidates(parts), usageMetadata, modelVersion: MODEL, responseId: 'resp-fixture-1' }, +}); + +const weatherTool = tool({ + description: 'Get the current weather for a city.', + inputSchema: jsonSchema({ + type: 'object', + properties: { city: { type: 'string', description: 'City name' } }, + required: ['city'], + additionalProperties: false, + }), +}); + +export default [ + { + name: 'generate-basic', + build: (mock) => provider(mock)(MODEL), + call: (model, run) => run('generateText', { prompt: 'Say hello.' }), + response: ok(), + }, + { + name: 'generate-stream-basic', + build: (mock) => provider(mock)(MODEL), + call: (model, run) => run('streamText', { prompt: 'Say hello.' }), + response: { + url: `${BASE}/models/${MODEL}:streamGenerateContent?alt=sse`, + status: 200, + headers: SSE_HEADERS, + body: sse([ + { data: { candidates: candidates([{ text: 'Hello' }], undefined), modelVersion: MODEL, responseId: 'resp-fixture-2' } }, + { data: { candidates: candidates([{ text: ' world' }]), usageMetadata: { promptTokenCount: 4, candidatesTokenCount: 2, totalTokenCount: 6 }, modelVersion: MODEL, responseId: 'resp-fixture-2' } }, + ]), + }, + }, + { + name: 'generate-tools', + build: (mock) => provider(mock)(MODEL), + call: (model, run) => + run('generateText', { + prompt: 'What is the weather in Paris?', + tools: { get_weather: weatherTool }, + toolChoice: 'required', + }), + response: ok([{ functionCall: { name: 'get_weather', args: { city: 'Paris' } } }]), + }, + { + name: 'generate-provider-options', + build: (mock) => provider(mock)(MODEL), + call: (model, run) => + run('generateText', { + prompt: 'Say hello.', + providerOptions: { + google: { + thinkingConfig: { thinkingBudget: 1024, includeThoughts: true }, + safetySettings: [{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH' }], + }, + }, + }), + response: ok(), + }, + { + name: 'embedding-basic', + build: (mock) => provider(mock).embedding('gemini-embedding-001'), + call: (model, run) => run('embedMany', { values: ['sunny day at the beach', 'rainy day in the city'] }), + response: { + url: `${BASE}/models/gemini-embedding-001:batchEmbedContents`, + status: 200, + headers: JSON_HEADERS, + body: { embeddings: [{ values: [0.125, -0.25, 0.5] }, { values: [0.0625, 0.375, -0.5] }] }, + }, + }, +]; diff --git a/scripts/aisdk-fixtures/cases/openai-compatible.mjs b/scripts/aisdk-fixtures/cases/openai-compatible.mjs new file mode 100644 index 000000000..55e3baa8f --- /dev/null +++ b/scripts/aisdk-fixtures/cases/openai-compatible.mjs @@ -0,0 +1,116 @@ +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { API_KEY, JSON_HEADERS, SSE_HEADERS, sse } from '../common.mjs'; + +const BASE = 'https://api.groq.com/openai/v1'; + +const completion = { + id: 'chatcmpl-fixture-1', + object: 'chat.completion', + created: 1735689600, + model: 'llama-3.3-70b-versatile', + choices: [{ index: 0, message: { role: 'assistant', content: 'Hello! How can I help you today?' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 9, total_tokens: 21 }, +}; + +const ok = (url = `${BASE}/chat/completions`, headers = JSON_HEADERS) => ({ url, status: 200, headers, body: completion }); + +const groq = (mock, extra = {}) => createOpenAICompatible({ name: 'groq', baseURL: BASE, apiKey: API_KEY, fetch: mock, ...extra }); +const MODEL = 'llama-3.3-70b-versatile'; + +const chunk = (delta, finish_reason = null) => ({ + id: 'chatcmpl-fixture-2', + object: 'chat.completion.chunk', + created: 1735689600, + model: MODEL, + choices: [{ index: 0, delta, finish_reason }], +}); + +export default [ + { + name: 'chat-basic', + build: (mock) => groq(mock).chatModel(MODEL), + call: (model, run) => run('generateText', { prompt: 'Say hello.' }), + response: ok(), + }, + { + name: 'chat-stream-basic', + build: (mock) => groq(mock).chatModel(MODEL), + call: (model, run) => run('streamText', { prompt: 'Say hello.' }), + response: { + url: `${BASE}/chat/completions`, + status: 200, + headers: SSE_HEADERS, + body: sse([ + { data: chunk({ role: 'assistant', content: '' }) }, + { data: chunk({ content: 'Hello' }) }, + { data: chunk({ content: ' world' }) }, + { data: chunk({}, 'stop') }, + { data: { id: 'chatcmpl-fixture-2', object: 'chat.completion.chunk', created: 1735689600, model: MODEL, choices: [], usage: { prompt_tokens: 12, completion_tokens: 2, total_tokens: 14 } } }, + 'data: [DONE]', + ]), + }, + }, + { + name: 'embedding-basic', + build: (mock) => groq(mock).embeddingModel('text-embedding-fixture'), + call: (model, run) => run('embedMany', { values: ['sunny day at the beach', 'rainy day in the city'] }), + response: { + url: `${BASE}/embeddings`, + status: 200, + headers: JSON_HEADERS, + body: { + object: 'list', + data: [ + { object: 'embedding', index: 0, embedding: [0.125, -0.25, 0.5] }, + { object: 'embedding', index: 1, embedding: [0.0625, 0.375, -0.5] }, + ], + model: 'text-embedding-fixture', + usage: { prompt_tokens: 10, total_tokens: 10 }, + }, + }, + }, + { + name: 'chat-provider-options-namespace', + build: (mock) => groq(mock).chatModel(MODEL), + call: (model, run) => + run('generateText', { + prompt: 'Say hello.', + providerOptions: { groq: { foo: 'bar' }, openaiCompatible: { user: 'u1' } }, + }), + response: ok(), + observe: ({ model, request }) => { + if (model.provider !== 'groq.chat') throw new Error(`expected provider "groq.chat", got "${model.provider}"`); + return { bodyHasFooAtTopLevel: request.body.foo === 'bar', bodyUser: request.body.user, providerIsGroqChat: true }; + }, + }, + { + // Extra: unknown fields under the generic `openaiCompatible` key (as opposed to the provider's + // own name) are NOT forwarded; only the schema-known ones (user, reasoningEffort, ...) are used. + name: 'chat-provider-options-generic-key-unknown', + build: (mock) => groq(mock).chatModel(MODEL), + call: (model, run) => + run('generateText', { prompt: 'Say hello.', providerOptions: { openaiCompatible: { user: 'u1', extra_field: 'x' } } }), + response: ok(), + observe: ({ request }) => ({ bodyHasExtraField: 'extra_field' in request.body }), + }, + { + name: 'chat-no-api-key', + env: {}, + build: (mock) => createOpenAICompatible({ name: 'local', baseURL: 'http://localhost:1234/v1', fetch: mock }).chatModel('local-model'), + call: (model, run) => run('generateText', { prompt: 'Say hello.' }), + response: ok('http://localhost:1234/v1/chat/completions'), + observe: ({ request }) => { + const has = Object.keys(request.headers).some((k) => k === 'authorization'); + if (has) throw new Error('unexpected authorization header'); + return { hasAuthorizationHeader: has }; + }, + }, + { + name: 'chat-query-params-and-headers', + build: (mock) => + groq(mock, { queryParams: { 'api-version': '1' }, headers: { 'x-a': '1', 'X-B': '2' } }).chatModel(MODEL), + call: (model, run) => run('generateText', { prompt: 'Say hello.', headers: { 'x-b': '3', 'x-c': '4' } }), + response: ok(`${BASE}/chat/completions?api-version=1`), + observe: ({ request }) => ({ headerNamesAsSent: request.rawHeaderNames.filter((n) => /^x-/i.test(n)).sort() }), + }, +]; diff --git a/scripts/aisdk-fixtures/cases/openai.mjs b/scripts/aisdk-fixtures/cases/openai.mjs new file mode 100644 index 000000000..c882e0679 --- /dev/null +++ b/scripts/aisdk-fixtures/cases/openai.mjs @@ -0,0 +1,233 @@ +import { jsonSchema, tool } from 'ai'; +import { createOpenAI } from '@ai-sdk/openai'; +import { API_KEY, ENV_KEY, JSON_HEADERS, SSE_HEADERS, sse } from '../common.mjs'; + +const CHAT_URL = 'https://api.openai.com/v1/chat/completions'; + +const chatCompletion = (message, finish_reason = 'stop', extra = {}) => ({ + id: 'chatcmpl-fixture-1', + object: 'chat.completion', + created: 1735689600, + model: 'gpt-4o-2024-08-06', + choices: [{ index: 0, message, finish_reason }], + usage: { + prompt_tokens: 12, + completion_tokens: 9, + total_tokens: 21, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + system_fingerprint: 'fp_fixture', + ...extra, +}); + +const chatOk = (content = 'Hello! How can I help you today?') => ({ + url: CHAT_URL, + status: 200, + headers: { ...JSON_HEADERS, 'x-request-id': 'req_fixture_1' }, + body: chatCompletion({ role: 'assistant', content }), +}); + +const provider = (mock, extra = {}) => createOpenAI({ apiKey: API_KEY, fetch: mock, ...extra }); + +const weatherTool = tool({ + description: 'Get the current weather for a city.', + inputSchema: jsonSchema({ + type: 'object', + properties: { city: { type: 'string', description: 'City name' } }, + required: ['city'], + additionalProperties: false, + }), +}); + +const chunk = (delta, finish_reason = null, extra = {}) => ({ + id: 'chatcmpl-fixture-2', + object: 'chat.completion.chunk', + created: 1735689600, + model: 'gpt-4o-2024-08-06', + system_fingerprint: 'fp_fixture', + choices: [{ index: 0, delta, finish_reason }], + ...extra, +}); + +export default [ + { + name: 'chat-basic', + build: (mock) => provider(mock).chat('gpt-4o'), + call: (model, run) => run('generateText', { prompt: 'Say hello.' }), + response: chatOk(), + }, + { + name: 'chat-system-and-settings', + build: (mock) => provider(mock).chat('gpt-4o'), + call: (model, run) => + run('generateText', { + system: 'You are a terse assistant.', + messages: [{ role: 'user', content: 'Name a color.' }], + temperature: 0.3, + maxOutputTokens: 64, + topP: 0.9, + stopSequences: ['END', '###'], + seed: 42, + }), + response: chatOk('Blue.'), + }, + { + name: 'chat-tools', + build: (mock) => provider(mock).chat('gpt-4o'), + call: (model, run) => + run('generateText', { + prompt: 'What is the weather in Paris?', + tools: { get_weather: weatherTool }, + toolChoice: 'required', + }), + response: { + url: CHAT_URL, + status: 200, + headers: JSON_HEADERS, + body: chatCompletion( + { + role: 'assistant', + content: null, + tool_calls: [{ id: 'call_fixture_1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }], + }, + 'tool_calls', + ), + }, + }, + { + name: 'chat-provider-options', + build: (mock) => provider(mock).chat('gpt-4o'), + call: (model, run) => + run('generateText', { + prompt: 'What is the weather in Paris?', + tools: { get_weather: weatherTool }, + providerOptions: { openai: { parallelToolCalls: false, user: 'u1' } }, + }), + response: chatOk(), + }, + { + name: 'chat-headers-merge', + build: (mock) => provider(mock, { headers: { 'x-a': '1', 'X-B': '2' } }).chat('gpt-4o'), + call: (model, run) => run('generateText', { prompt: 'Say hello.', headers: { 'x-b': '3', 'x-c': '4' } }), + response: chatOk(), + observe: ({ request }) => ({ + headerNamesAsSent: request.rawHeaderNames.filter((n) => /^x-/i.test(n)).sort(), + xB: request.headers['x-b'], + }), + }, + { + name: 'chat-custom-name', + build: (mock) => provider(mock, { name: 'proxy' }).chat('gpt-4o'), + call: (model, run) => run('generateText', { prompt: 'Say hello.' }), + response: chatOk(), + observe: ({ model }) => { + if (model.provider !== 'proxy.chat') throw new Error(`expected provider "proxy.chat", got "${model.provider}"`); + return { providerIsProxyChat: true }; + }, + }, + { + name: 'chat-stream-basic', + build: (mock) => provider(mock).chat('gpt-4o'), + call: (model, run) => run('streamText', { prompt: 'Say hello.' }), + response: { + url: CHAT_URL, + status: 200, + headers: SSE_HEADERS, + body: sse([ + { data: chunk({ role: 'assistant', content: '' }) }, + { data: chunk({ content: 'Hello' }) }, + { data: chunk({ content: ' world' }) }, + { data: chunk({}, 'stop') }, + { + data: { + id: 'chatcmpl-fixture-2', + object: 'chat.completion.chunk', + created: 1735689600, + model: 'gpt-4o-2024-08-06', + choices: [], + usage: { prompt_tokens: 12, completion_tokens: 2, total_tokens: 14 }, + }, + }, + 'data: [DONE]', + ]), + }, + }, + { + name: 'responses-basic', + build: (mock) => provider(mock).responses('gpt-4o'), + call: (model, run) => run('generateText', { prompt: 'Say hello.' }), + response: { + url: 'https://api.openai.com/v1/responses', + status: 200, + headers: JSON_HEADERS, + body: { + id: 'resp_fixture_1', + object: 'response', + created_at: 1735689600, + status: 'completed', + error: null, + incomplete_details: null, + model: 'gpt-4o-2024-08-06', + output: [ + { + type: 'message', + id: 'msg_fixture_1', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'Hello! How can I help you today?', annotations: [] }], + }, + ], + usage: { + input_tokens: 12, + output_tokens: 9, + total_tokens: 21, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }, + }, + { + name: 'embedding-basic', + build: (mock) => provider(mock).embedding('text-embedding-3-small'), + call: (model, run) => run('embedMany', { values: ['sunny day at the beach', 'rainy day in the city'] }), + response: { + url: 'https://api.openai.com/v1/embeddings', + status: 200, + headers: JSON_HEADERS, + body: { + object: 'list', + data: [ + { object: 'embedding', index: 0, embedding: [0.125, -0.25, 0.5] }, + { object: 'embedding', index: 1, embedding: [0.0625, 0.375, -0.5] }, + ], + model: 'text-embedding-3-small', + usage: { prompt_tokens: 10, total_tokens: 10 }, + }, + }, + }, + { + name: 'missing-api-key', + env: { OPENAI_API_KEY: undefined }, + build: (mock) => createOpenAI({ fetch: mock }).chat('gpt-4o'), + call: (model, run) => run('generateText', { prompt: 'Say hello.' }), + expectError: true, + expectRequests: 0, + response: chatOk(), + observe: ({ requests }) => ({ requestsSent: requests.length }), + }, + { + name: 'empty-api-key', + env: { OPENAI_API_KEY: ENV_KEY }, + // Pins the "empty string does not fall back to env" rule: whatever the SDK does is recorded. + build: (mock) => createOpenAI({ apiKey: '', fetch: mock }).chat('gpt-4o'), + call: (model, run) => run('generateText', { prompt: 'Say hello.' }), + response: chatOk(), + observe: ({ request }) => ({ + authorizationIsEmptyBearer: request.rawHeaders.authorization?.trim() === 'Bearer', + authorizationRawLength: request.rawHeaders.authorization?.length, + usedEnvKey: (request.rawHeaders.authorization ?? '').includes(ENV_KEY), + }), + }, +]; diff --git a/scripts/aisdk-fixtures/common.mjs b/scripts/aisdk-fixtures/common.mjs new file mode 100644 index 000000000..d6c43da13 --- /dev/null +++ b/scripts/aisdk-fixtures/common.mjs @@ -0,0 +1,27 @@ +// Shared constants for the fixture recorder. Keys are obviously fake; they are +// redacted from every fixture and the recorder fails if one ever leaks. +export const API_KEY = 'sk-test-fixture'; +export const ENV_KEY = 'sk-env-should-not-be-used'; +export const FAKE_KEYS = [API_KEY, ENV_KEY]; + +// Deterministic clock: every `new Date()` / `Date.now()` returns this instant. +export const FIXED_NOW_MS = Date.UTC(2025, 0, 1, 0, 0, 0); + +/** Build an SSE text body from event objects (JSON `data:` lines) or raw strings. */ +export function sse(events) { + return ( + events + .map((e) => { + if (typeof e === 'string') return e; + const { event, data } = e; + return `${event ? `event: ${event}\n` : ''}data: ${typeof data === 'string' ? data : JSON.stringify(data)}`; + }) + .join('\n\n') + '\n\n' + ); +} + +export const JSON_HEADERS = { 'content-type': 'application/json' }; +export const SSE_HEADERS = { + 'cache-control': 'no-cache', + 'content-type': 'text/event-stream', +}; diff --git a/scripts/aisdk-fixtures/mock-fetch.mjs b/scripts/aisdk-fixtures/mock-fetch.mjs new file mode 100644 index 000000000..585cc7d7a --- /dev/null +++ b/scripts/aisdk-fixtures/mock-fetch.mjs @@ -0,0 +1,81 @@ +// Mock transport. `createMockFetch(canned)` returns a fetch-compatible function that +// records every outgoing request and answers with the canned response. It never touches +// the network: a request to any URL other than `canned.url`, or a second request, throws. + +function lowerSortedHeaders(entries) { + const out = {}; + for (const [k, v] of entries.map(([k, v]) => [String(k).toLowerCase(), String(v)]).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + out[k] = v; + } + return out; +} + +function headerEntries(headers) { + if (headers == null) return []; + if (typeof Headers !== 'undefined' && headers instanceof Headers) return [...headers.entries()]; + if (Array.isArray(headers)) return headers; + return Object.entries(headers); +} + +export function createMockFetch(canned) { + const requests = []; + + async function mockFetch(input, init = {}) { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const method = (init.method ?? (typeof input === 'object' && input.method) ?? 'GET').toUpperCase(); + const rawEntries = headerEntries(init.headers); + const headers = lowerSortedHeaders(rawEntries); + + let text; + const b = init.body; + if (b == null) text = undefined; + else if (typeof b === 'string') text = b; + else if (b instanceof Uint8Array || b instanceof ArrayBuffer) text = new TextDecoder().decode(b); + else throw new Error(`mock fetch: unsupported request body type ${Object.prototype.toString.call(b)}`); + + let body = text; + if (text !== undefined && (headers['content-type'] ?? '').includes('json')) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + requests.push({ method, url, headers, body, rawHeaderNames: rawEntries.map(([k]) => String(k)), rawHeaders: Object.fromEntries(rawEntries.map(([k, v]) => [String(k), String(v)])) }); + + if (requests.length > 1 || !canned || url !== canned.url) { + throw new Error(`mock fetch: unmocked request #${requests.length} ${method} ${url} (expected ${canned?.url ?? ''})`); + } + + const status = canned.status ?? 200; + const respHeaders = { ...(canned.headers ?? {}) }; + const isSse = Object.entries(respHeaders).some(([k, v]) => k.toLowerCase() === 'content-type' && /text\/event-stream/i.test(v)); + let payload; + if (typeof canned.body === 'string') { + if (isSse) { + const enc = new TextEncoder(); + const events = canned.body.split('\n\n').filter((e) => e.length > 0).map((e) => enc.encode(e + '\n\n')); + payload = new ReadableStream({ + start(controller) { + for (const e of events) controller.enqueue(e); + controller.close(); + }, + }); + } else payload = canned.body; + } else { + payload = JSON.stringify(canned.body); + if (!Object.keys(respHeaders).some((k) => k.toLowerCase() === 'content-type')) respHeaders['content-type'] = 'application/json'; + } + const response = new Response(payload, { status, headers: respHeaders }); + const last = requests[requests.length - 1]; + last.responseStatus = response.status; + last.responseHeaders = Object.fromEntries(response.headers.entries()); + return response; + } + + mockFetch.requests = requests; + mockFetch.canned = canned; + return mockFetch; +} + +export { lowerSortedHeaders }; diff --git a/scripts/aisdk-fixtures/package-lock.json b/scripts/aisdk-fixtures/package-lock.json new file mode 100644 index 000000000..144b2c2b7 --- /dev/null +++ b/scripts/aisdk-fixtures/package-lock.json @@ -0,0 +1,205 @@ +{ + "name": "aimux-aisdk-fixtures", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aimux-aisdk-fixtures", + "version": "0.0.0", + "dependencies": { + "@ai-sdk/anthropic": "4.0.71", + "@ai-sdk/google": "4.0.87", + "@ai-sdk/openai": "4.0.83", + "@ai-sdk/openai-compatible": "3.0.62", + "@ai-sdk/provider": "4.0.21", + "@ai-sdk/provider-utils": "5.0.53", + "ai": "7.0.127" + } + }, + "node_modules/@ai-sdk/anthropic": { + "version": "4.0.71", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.71.tgz", + "integrity": "sha512-wiv3jhUH0RrvGzSolJMZfZ2cI9As45LhwYmcAoWvNivXjC7wzJ4W/Udq9/u7EGnGsT8lpmFKEKBrMXVeUO97Dg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.21", + "@ai-sdk/provider-utils": "5.0.53" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.103", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.103.tgz", + "integrity": "sha512-nnGTUHzPRQ14jhv9CL4OQrzcRBIFTBMIPKGIi9Z9u/4GtwSRxa/nxUX6np2Iwp5phOzO0a0njj/j9Jolf5AEaA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.21", + "@ai-sdk/provider-utils": "5.0.53", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "4.0.87", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-4.0.87.tgz", + "integrity": "sha512-s0tWc+QeSce7O2ZLm+VWSRQy16dGfqEcMIUxEWh+LFvXX3h0TLH8jxCQ2H1qhiBgTta+6A97eHPhBlRFu1xy+g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.21", + "@ai-sdk/provider-utils": "5.0.53" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "4.0.83", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.83.tgz", + "integrity": "sha512-NgqZVWoya7hfmtLkWyIjX9THbyYMLfxFqpShdAmvMnkoT+R9AZbdULGan8+4BiBWIZ1aHiAsc90l3SL8QU/0+Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.21", + "@ai-sdk/provider-utils": "5.0.53" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "3.0.62", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-3.0.62.tgz", + "integrity": "sha512-IAz1xv8zot4KLGa8kN7UfAA6H5+ARzAgAKLsoKxHPBzZuOc2E3ohaELg0x/JR9HVTbMbiftb0dPyCb8cT2KBRg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.21", + "@ai-sdk/provider-utils": "5.0.53" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.21", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.21.tgz", + "integrity": "sha512-UpbC9C1oht8dhfKPbVXSLRZS3DI8uk8n5v2uMjBPNIYGsC2kL045ywq7D8Hh9KgnH9rP5W/GxQdYtWScRouqlA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.53", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.53.tgz", + "integrity": "sha512-VVe6UDd0y0/B4TfauuKKzcppqvhTmSluwwUVT4WG2/8h7gFbyFm7PM4UGM+ZXe3/RI6bv+Vl1cw/7aiBoQqdxg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.21", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.29.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, + "node_modules/ai": { + "version": "7.0.127", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.127.tgz", + "integrity": "sha512-JNsNPk4ZvRGEJuqdVo2lYFkm7uAuf3p/RwKFWn1Wh/+IK3U1RyFWw2GXoFnhzqNfxEuj7XJB4WpIUhl1sHToWg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.103", + "@ai-sdk/provider": "4.0.21", + "@ai-sdk/provider-utils": "5.0.53" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/scripts/aisdk-fixtures/package.json b/scripts/aisdk-fixtures/package.json new file mode 100644 index 000000000..5014ec873 --- /dev/null +++ b/scripts/aisdk-fixtures/package.json @@ -0,0 +1,19 @@ +{ + "name": "aimux-aisdk-fixtures", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Records Vercel AI SDK request/response fixtures under a mocked fetch transport (RFC-0036 section 9.3).", + "scripts": { + "record": "node record.mjs" + }, + "dependencies": { + "@ai-sdk/anthropic": "4.0.71", + "@ai-sdk/google": "4.0.87", + "@ai-sdk/openai": "4.0.83", + "@ai-sdk/openai-compatible": "3.0.62", + "@ai-sdk/provider": "4.0.21", + "@ai-sdk/provider-utils": "5.0.53", + "ai": "7.0.127" + } +} diff --git a/scripts/aisdk-fixtures/record.mjs b/scripts/aisdk-fixtures/record.mjs new file mode 100644 index 000000000..d1cfd4a0a --- /dev/null +++ b/scripts/aisdk-fixtures/record.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +// Protocol fixture recorder: runs the official Vercel AI SDK packages against a mock +// `fetch` and writes what was sent / returned to fixtures/aisdk//.json. +// See README.md. Usage: `npm ci && node record.mjs [package[/case] ...]`. +import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateText, streamText, embed, embedMany, generateImage } from 'ai'; +import { createMockFetch, lowerSortedHeaders } from './mock-fetch.mjs'; +import { FAKE_KEYS, FIXED_NOW_MS } from './common.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(here, '../../fixtures/aisdk'); + +const PACKAGES = [ + { dir: 'openai', pkg: '@ai-sdk/openai' }, + { dir: 'openai-compatible', pkg: '@ai-sdk/openai-compatible' }, + { dir: 'anthropic', pkg: '@ai-sdk/anthropic' }, + { dir: 'google', pkg: '@ai-sdk/google' }, +]; +const VERSIONED = ['ai', '@ai-sdk/provider', '@ai-sdk/provider-utils', ...PACKAGES.map((p) => p.pkg)]; + +function installedVersion(pkg) { + return JSON.parse(readFileSync(join(here, 'node_modules', pkg, 'package.json'), 'utf8')).version; +} + +// ---------------------------------------------------------------- determinism +const RealDate = Date; +class FixedDate extends RealDate { + constructor(...args) { + if (args.length === 0) super(FIXED_NOW_MS); + else super(...args); + } + static now() { + return FIXED_NOW_MS; + } +} +globalThis.Date = FixedDate; + +// The provider-utils user-agent embeds `runtime/`; pin it so the +// fixtures do not depend on the Node major version of whoever regenerates them. +Object.defineProperty(globalThis, 'navigator', { value: { userAgent: 'Node.js/22' }, configurable: true, writable: true }); + +function seedRandom(seed = 0x2f6e2b1) { + let a = seed >>> 0; + Math.random = () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function makeIdGenerator() { + let n = 0; + return () => `id-${++n}`; +} + +// ---------------------------------------------------------------- serialization +function sortKeys(v) { + if (Array.isArray(v)) return v.map(sortKeys); + if (v && typeof v === 'object') { + return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])); + } + return v; +} + +function replacer(_key, value) { + if (value instanceof Error) return { name: value.name, message: value.message }; + if (value instanceof Uint8Array) return { uint8ArrayLength: value.length }; + if (typeof value === 'bigint') return value.toString(); + // AI SDK tool objects: keep description + the JSON schema, drop functions/symbols. + if (value && typeof value === 'object' && 'inputSchema' in value && !Array.isArray(value)) { + const s = value.inputSchema; + return { description: value.description, inputSchema: s && typeof s === 'object' && 'jsonSchema' in s ? s.jsonSchema : s }; + } + return value; +} + +/** JSON-roundtrip with SDK-object handling, then key-sorted. */ +function toPlain(v) { + const s = JSON.stringify(v, replacer); + return s === undefined ? undefined : sortKeys(JSON.parse(s)); +} + +function redactHeaders(headers) { + const secretNames = new Set(['authorization', 'x-api-key', 'api-key', 'x-goog-api-key']); + const out = {}; + for (const [k, v] of Object.entries(headers)) { + const name = k.toLowerCase(); + const leaks = FAKE_KEYS.some((key) => key !== '' && v.includes(key)); + out[name] = secretNames.has(name) || leaks ? '' : v; + } + return sortKeys(out); +} + +// ---------------------------------------------------------------- SDK operations +function generateTextResult(r) { + return { + content: r.content, + finishReason: r.finishReason, + providerMetadata: r.providerMetadata, + rawFinishReason: r.rawFinishReason, + response: { headers: r.response?.headers, id: r.response?.id, modelId: r.response?.modelId }, + text: r.text, + usage: r.usage, + warnings: r.warnings, + }; +} + +async function runOperation(operation, model, input, internal) { + switch (operation) { + case 'generateText': + return generateTextResult(await generateText({ model, ...input, _internal: internal })); + case 'streamText': { + const r = streamText({ model, ...input, _internal: internal, onError: () => {} }); + const parts = []; + for await (const p of r.fullStream) parts.push(p); + return { finishReason: await r.finishReason, parts, usage: await r.usage }; + } + case 'embed': { + const r = await embed({ model, ...input }); + return { embeddings: [r.embedding], providerMetadata: r.providerMetadata, usage: r.usage }; + } + case 'embedMany': { + const r = await embedMany({ model, ...input }); + return { embeddings: r.embeddings, providerMetadata: r.providerMetadata, usage: r.usage }; + } + case 'generateImage': { + const r = await generateImage({ model, ...input }); + return { + images: r.images.map((i) => ({ base64Length: i.base64?.length ?? 0, mediaType: i.mediaType })), + warnings: r.warnings, + }; + } + default: + throw new Error(`unknown operation ${operation}`); + } +} + +// ---------------------------------------------------------------- one case +const SCRUB_ENV = /^(OPENAI|ANTHROPIC|GOOGLE|GROQ|AI_GATEWAY|VERCEL)_/; + +async function recordCase({ pkg, version, c }) { + seedRandom(); + const generateId = makeIdGenerator(); + const internal = { generateId, generateCallId: generateId, now: () => FIXED_NOW_MS }; + + // Hermetic environment: no ambient provider env vars, plus whatever the case sets. + const savedEnv = { ...process.env }; + for (const k of Object.keys(process.env)) if (SCRUB_ENV.test(k)) delete process.env[k]; + for (const [k, v] of Object.entries(c.env ?? {})) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + + const mock = createMockFetch(c.response); + const ctx = { operation: undefined, input: undefined }; + let model; + let result; + let error; + try { + model = c.build(mock); + const run = (operation, input) => { + ctx.operation = operation; + ctx.input = toPlain(input); + return runOperation(operation, model, input, internal); + }; + try { + result = toPlain(await c.call(model, run)); + } catch (e) { + error = e; + } + } finally { + for (const k of Object.keys(process.env)) if (!(k in savedEnv)) delete process.env[k]; + Object.assign(process.env, savedEnv); + } + + if (c.expectError && !error) throw new Error(`${pkg}/${c.name}: expected an error, got a result`); + if (!c.expectError && error) throw new Error(`${pkg}/${c.name}: unexpected error: ${error.stack ?? error}`); + const expected = c.expectRequests ?? 1; + if (mock.requests.length !== expected) { + throw new Error(`${pkg}/${c.name}: expected ${expected} request(s), mock saw ${mock.requests.length}`); + } + + const raw = mock.requests[0]; + const observations = c.observe ? toPlain(c.observe({ request: raw, requests: mock.requests, result, error, model })) : undefined; + + const fixture = { + package: pkg, + version, + case: c.name, + sdk: { operation: ctx.operation, input: ctx.input }, + model: { modelId: model?.modelId, provider: model?.provider }, + request: raw ? { body: raw.body, headers: redactHeaders(raw.headers), method: raw.method, url: raw.url } : null, + response: raw ? { body: c.response.body, headers: lowerSortedHeaders(Object.entries(raw.responseHeaders)), status: raw.responseStatus } : null, + ...(error ? { error: { message: error.message, name: error.name } } : { result }), + ...(observations ? { observations } : {}), + }; + const text = JSON.stringify(sortKeys(JSON.parse(JSON.stringify(fixture, replacer))), null, 2) + '\n'; + for (const key of FAKE_KEYS) { + if (key && text.includes(key)) throw new Error(`${pkg}/${c.name}: API key "${key}" leaked into fixture`); + } + return text; +} + +// ---------------------------------------------------------------- main +const filters = process.argv.slice(2); +const wanted = (dir, name) => filters.length === 0 || filters.some((f) => f === dir || f === `${dir}/${name}`); + +const skipped = []; +let recorded = 0; +for (const { dir, pkg } of PACKAGES) { + const cases = (await import(`./cases/${dir}.mjs`)).default; + const version = installedVersion(pkg); + const pkgOut = join(OUT, dir); + mkdirSync(pkgOut, { recursive: true }); + const names = new Set(); + for (const c of cases) { + if (names.has(c.name)) throw new Error(`duplicate case ${dir}/${c.name}`); + names.add(c.name); + if (!wanted(dir, c.name)) continue; + if (c.skip) { + skipped.push({ dir, pkg, name: c.name, reason: c.skip }); + console.log(`skip ${dir}/${c.name}: ${c.skip}`); + continue; + } + const text = await recordCase({ pkg, version, c }); + writeFileSync(join(pkgOut, `${c.name}.json`), text); + recorded++; + console.log(`record ${dir}/${c.name}`); + } + if (filters.length === 0) { + // Drop fixtures of cases that no longer exist. + for (const f of readdirSync(pkgOut)) { + if (f.endsWith('.json') && !names.has(f.slice(0, -5))) rmSync(join(pkgOut, f)); + } + } +} + +if (filters.length === 0) { + const versions = Object.fromEntries(VERSIONED.map((p) => [p, installedVersion(p)])); + writeFileSync(join(OUT, 'VERSIONS.json'), JSON.stringify(sortKeys(versions), null, 2) + '\n'); + const skippedPath = join(OUT, 'SKIPPED.md'); + if (skipped.length) { + const lines = ['# Skipped cases', '', 'Generated by `scripts/aisdk-fixtures/record.mjs`. These cases could not be recorded against the pinned SDK versions.', '']; + for (const s of skipped) lines.push(`- \`${s.dir}/${s.name}\` (${s.pkg}): ${s.reason}`); + writeFileSync(skippedPath, lines.join('\n') + '\n'); + } else if (existsSync(skippedPath)) rmSync(skippedPath); +} +console.log(`done: ${recorded} recorded, ${skipped.length} skipped`); From 316127e9608d056dc9187a17f23b6ebcf62bc536 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:22:00 +0000 Subject: [PATCH 02/72] refactor(provider-utils)!: injectable Fetch, Resolvable settings, and call-level retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI SDK alignment design (docs/aisdk-architecture-alignment.md §3.2 / §4.5, impact map S1-1, S1-2, S1-3, S1-5). First commit group (A0) of the provider-factory rewrite: the primitives every createXxx(settings) factory needs, with no change to the factories themselves yet. Transport (S1-1) - `Fetch` trait with `FetchRequest` / `FetchResponse` / `FetchError`; `ReqwestFetch` carries the former `shared_client()` machinery (per-runtime client sharding, pool settings, proxy config); `default_fetch()` is resolved per request, never frozen at factory time. - `HttpRequest.fetch: Option` threads an injected transport through `post_*_to_api` / `get_from_api`; `send_request_once` sends through it. The SSRF download guard keeps its pinned-DNS client as `PinnedFetch` and ignores the injected fetch when `validate_url` is set (D26). - `SigV4Fetch` decorator signs the final request bytes (same algorithm and test vectors as `bedrock/sigv4.rs`; providers switch to it in A4). - `WsConnector` injection point on `WebSocketRequest`; `TungsteniteConnector` is today's behaviour. - `catalogue.rs` no longer builds its own reqwest client. Settings (S1-2, S1-3) - `Resolvable` (Value / Fn / AsyncFn / Future) and `HeadersFn`; `combine_headers` (lower-cased names, later layers override, `None` removes) and `normalize_headers`; requests insert headers instead of appending, so a credential header is never sent twice. - `load_api_key(Some(s))` returns `s` verbatim, including the empty string; only `None` falls back to the environment (pinned by the AI SDK fixture `openai/empty-api-key`). Missing values are `AiMuxError::LoadApiKey` / `LoadSetting`; `load_setting` / `load_optional_setting` added. The FFI, Node and Python mappings route the two new variants to the existing invalid-argument code until A5 adds dedicated codes. Retry (S1-5, D14) - `prepare_retries(max_retries, abort)` with the constant defaults 2 / 2000 ms / x2; `RetryConfig` is deleted along with every provider config field, `with_retry_config` builder and `retry_config()` trait method. Retry is a call-level concern: the nine core operations read only the caller's `max_retries`, provider-internal list/files/poll sites use the default budget for now (A4 applies the §4.5 table). Not in this commit: RecordingFetch (S1-4), the Provider trait reshape (A1), provider factories (A2–A4), FFI/binding error codes (A5). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/embedding_model.rs | 10 +- aimux-core/src/error.rs | 28 + aimux-core/src/generate.rs | 12 +- aimux-core/src/image_model.rs | 10 +- aimux-core/src/language_model.rs | 6 - aimux-core/src/moa.rs | 29 +- aimux-core/src/reranking_model.rs | 10 +- aimux-core/src/retry.rs | 80 +-- aimux-core/src/router.rs | 28 +- aimux-core/src/search_model.rs | 10 +- aimux-core/src/speech_model.rs | 10 +- aimux-core/src/trace/layer.rs | 4 - aimux-core/src/transcription_model.rs | 10 +- aimux-core/src/video_model.rs | 22 +- aimux-core/tests/error_value_golden_test.rs | 30 +- aimux-ffi/src/lib.rs | 4 + aimux-ffi/tests/exports_smoke_test.rs | 2 +- aimux-provider-utils/Cargo.toml | 4 + aimux-provider-utils/src/api_key.rs | 58 +- .../src/extract_response_headers.rs | 6 +- aimux-provider-utils/src/fetch.rs | 441 +++++++++++++++ .../src/handle_fetch_error.rs | 61 +- aimux-provider-utils/src/headers.rs | 122 ++++ aimux-provider-utils/src/http.rs | 344 ++++-------- aimux-provider-utils/src/lib.rs | 29 +- .../src/read_response_with_size_limit.rs | 6 +- aimux-provider-utils/src/resolvable.rs | 157 ++++++ aimux-provider-utils/src/response_handler.rs | 3 +- aimux-provider-utils/src/retry.rs | 3 - aimux-provider-utils/src/sigv4_fetch.rs | 527 ++++++++++++++++++ aimux-provider-utils/src/ws.rs | 136 ++++- .../tests/download_guard_test.rs | 1 + .../tests/fetch_injection_test.rs | 270 +++++++++ aimux-provider-utils/tests/load_api_key.rs | 90 ++- .../tests/ws_connector_test.rs | 45 ++ aimux-provider-utils/tests/ws_proxy_test.rs | 2 + aimux-providers/src/anthropic/files.rs | 6 +- aimux-providers/src/anthropic/mod.rs | 15 +- aimux-providers/src/anthropic/model.rs | 9 +- aimux-providers/src/anthropic_aws/mod.rs | 16 +- aimux-providers/src/anthropic_aws/model.rs | 7 - aimux-providers/src/assemblyai.rs | 6 +- aimux-providers/src/azure/model.rs | 20 +- aimux-providers/src/azure/responses.rs | 4 - aimux-providers/src/bedrock/embedding.rs | 4 - aimux-providers/src/bedrock/image.rs | 3 - aimux-providers/src/bedrock/mod.rs | 20 +- aimux-providers/src/bedrock/model.rs | 9 +- aimux-providers/src/bedrock/reranking.rs | 13 +- aimux-providers/src/bedrock/sigv4.rs | 226 +------- aimux-providers/src/black_forest_labs.rs | 8 +- aimux-providers/src/catalogue.rs | 62 +-- aimux-providers/src/codex.rs | 21 +- aimux-providers/src/cohere/embedding.rs | 4 - aimux-providers/src/cohere/mod.rs | 14 +- aimux-providers/src/cohere/model.rs | 4 - aimux-providers/src/cohere/reranking.rs | 4 - aimux-providers/src/elevenlabs.rs | 1 + aimux-providers/src/fal.rs | 18 +- aimux-providers/src/gladia.rs | 7 +- aimux-providers/src/google/embedding.rs | 4 - aimux-providers/src/google/files.rs | 9 +- aimux-providers/src/google/image.rs | 4 - aimux-providers/src/google/mod.rs | 14 +- aimux-providers/src/google/model.rs | 4 - aimux-providers/src/google/video.rs | 3 - aimux-providers/src/huggingface.rs | 8 +- aimux-providers/src/huggingface/responses.rs | 4 - aimux-providers/src/luma.rs | 7 +- aimux-providers/src/mistral/embedding.rs | 4 - aimux-providers/src/mistral/mod.rs | 14 +- aimux-providers/src/mistral/model.rs | 4 - aimux-providers/src/openai/embedding.rs | 4 - aimux-providers/src/openai/files.rs | 6 +- aimux-providers/src/openai/image.rs | 4 - aimux-providers/src/openai/mod.rs | 17 +- aimux-providers/src/openai/model.rs | 7 +- aimux-providers/src/openai/responses/mod.rs | 4 - aimux-providers/src/openai/speech.rs | 4 - aimux-providers/src/openai/transcription.rs | 5 +- aimux-providers/src/provider.rs | 13 +- aimux-providers/src/recraft.rs | 8 +- aimux-providers/src/replay.rs | 3 - aimux-providers/src/replicate.rs | 10 +- aimux-providers/src/revai.rs | 6 +- aimux-providers/src/vertex/anthropic_model.rs | 8 +- aimux-providers/src/vertex/embedding.rs | 4 - aimux-providers/src/vertex/image.rs | 3 - aimux-providers/src/vertex/mod.rs | 26 +- aimux-providers/src/vertex/model.rs | 8 +- aimux-providers/src/vertex/transcription.rs | 13 +- aimux-providers/src/vertex/video.rs | 12 +- aimux-providers/src/xai/mod.rs | 6 +- aimux-providers/src/xai/model.rs | 4 - aimux-providers/src/xai/responses/mod.rs | 4 - .../tests/anthropic_aws_model_test.rs | 2 - .../tests/bedrock_embedding_test.rs | 1 - aimux-providers/tests/bedrock_model_test.rs | 2 - .../tests/bedrock_reasoning_test.rs | 1 - .../tests/bedrock_remaining_test.rs | 3 - aimux-providers/tests/config_snapshot_test.rs | 6 +- .../tests/data_loss_regression_test.rs | 1 - aimux-providers/tests/google_files_test.rs | 37 +- aimux-providers/tests/google_video_test.rs | 25 +- aimux-providers/tests/list_models_test.rs | 12 +- aimux-providers/tests/openai_files_test.rs | 11 +- aimux-providers/tests/openai_model_test.rs | 13 +- aimux-providers/tests/provider_error_test.rs | 29 +- .../tests/vertex_anthropic_test.rs | 1 - .../tests/vertex_embedding_test.rs | 2 - aimux-providers/tests/vertex_model_test.rs | 2 - bindings/node/index.d.ts | 5 +- bindings/node/src/error.rs | 3 + bindings/node/src/lib.rs | 9 +- bindings/node/src/types/AiMuxError.ts | 18 +- bindings/python/src/error.rs | 5 + tools/aimux-web/src/api/mod.rs | 2 + 117 files changed, 2277 insertions(+), 1282 deletions(-) create mode 100644 aimux-provider-utils/src/fetch.rs create mode 100644 aimux-provider-utils/src/resolvable.rs delete mode 100644 aimux-provider-utils/src/retry.rs create mode 100644 aimux-provider-utils/src/sigv4_fetch.rs create mode 100644 aimux-provider-utils/tests/fetch_injection_test.rs create mode 100644 aimux-provider-utils/tests/ws_connector_test.rs diff --git a/aimux-core/src/embedding_model.rs b/aimux-core/src/embedding_model.rs index 7f1807048..c6ba1011a 100644 --- a/aimux-core/src/embedding_model.rs +++ b/aimux-core/src/embedding_model.rs @@ -128,10 +128,6 @@ pub trait EmbeddingModel: Send + Sync { /// Provider-specific model ID, e.g. `"text-embedding-3-small"`. fn model_id(&self) -> &str; - fn retry_config(&self) -> crate::retry::RetryConfig { - crate::retry::RetryConfig::default() - } - /// Limit of how many embeddings can be generated in a single API call. /// /// `None` means the model has no fixed limit. The TS spec allows this to @@ -159,11 +155,7 @@ pub async fn embed( ) -> Result { let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?; let abort_signal = options.abort_signal.clone(); - let retries = retry::prepare_retries( - options.max_retries, - model.retry_config(), - abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, abort_signal.clone()); timeout::run( retries.retry(|| model.do_embed(&options)), abort_signal.as_ref(), diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index e5796b5af..b8d7db23f 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -215,6 +215,34 @@ pub enum AiMuxError { #[error("invalid prompt: {0}")] InvalidPrompt(String), + /// No API key was passed and the fallback environment variable is unset + /// (the AI SDK's `LoadAPIKeyError`). Pre-HTTP: no request was made. + #[error( + "No API key found for {description}. \ + Please provide it via the `api_key` parameter \ + or set the `{env_var}` environment variable." + )] + LoadApiKey { + /// The environment variable consulted as the fallback. + env_var: String, + /// What the key is for, e.g. `"OpenAI"`. + description: String, + }, + + /// A required provider setting was not passed and its fallback + /// environment variable is unset (the AI SDK's `LoadSettingError`). + #[error( + "No value found for setting `{name}`. \ + Please provide it via the `{name}` parameter \ + or set the `{env_var}` environment variable." + )] + LoadSetting { + /// The environment variable consulted as the fallback. + env_var: String, + /// The setting's parameter name, e.g. `"region"`. + name: String, + }, + /// The access token has expired (or was invalidated) and must be refreshed /// by the caller. RFC-0018 subscription mode: the library maps a 401 from /// the Codex subscription endpoint to this variant; the integrator diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 274939bb7..627796646 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -563,11 +563,7 @@ pub async fn generate_text( let operation_timeout = timeout::OperationTimeout::new(call_options.timeout.unwrap_or_default())?; let abort_signal = call_options.abort_signal.clone(); - let retries = retry::prepare_retries( - call_options.max_retries, - model.retry_config(), - abort_signal.clone(), - ); + let retries = retry::prepare_retries(call_options.max_retries, abort_signal.clone()); // 2a. RFC-0023: 关闭时零成本(M2 评审)——仅在录制开启时生成 call_id // 并绑定 recorder 快照;传输封闭由层 A 收尾声明(P1 无层 B,barrier @@ -915,11 +911,7 @@ pub async fn stream_text( .map(|duration_ms| timeout::TimeoutDeadline::from_now("First chunk", duration_ms)) .transpose()?; let abort_signal = call_options.abort_signal.clone(); - let retries = retry::prepare_retries( - call_options.max_retries, - model.retry_config(), - abort_signal.clone(), - ); + let retries = retry::prepare_retries(call_options.max_retries, abort_signal.clone()); // 2a. RFC-0023: 关闭时零成本(M2 评审)——仅在录制开启时生成 call_id // 并绑定 recorder 快照;传输封闭由层 A 收尾声明(P1 无层 B,barrier diff --git a/aimux-core/src/image_model.rs b/aimux-core/src/image_model.rs index 2a1ae9418..1a04fbe26 100644 --- a/aimux-core/src/image_model.rs +++ b/aimux-core/src/image_model.rs @@ -192,10 +192,6 @@ pub trait ImageModel: Send + Sync { /// Provider-specific model ID, e.g. `"dall-e-3"`. fn model_id(&self) -> &str; - fn retry_config(&self) -> crate::retry::RetryConfig { - crate::retry::RetryConfig::default() - } - /// Limit of how many images can be generated in a single API call. /// /// `None` means there is no fixed limit. The TS spec allows this to be a @@ -220,11 +216,7 @@ pub async fn generate_image( ) -> Result { let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?; let abort_signal = options.abort_signal.clone(); - let retries = retry::prepare_retries( - options.max_retries, - model.retry_config(), - abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, abort_signal.clone()); timeout::run( retries.retry(|| model.do_generate(&options)), abort_signal.as_ref(), diff --git a/aimux-core/src/language_model.rs b/aimux-core/src/language_model.rs index c6a37f50f..55315e84c 100644 --- a/aimux-core/src/language_model.rs +++ b/aimux-core/src/language_model.rs @@ -34,12 +34,6 @@ pub trait LanguageModel: Send + Sync { /// Model identifier, e.g. `"gpt-4o"`. fn model_id(&self) -> &str; - /// Provider/model retry settings used when the caller does not override - /// the retry count. - fn retry_config(&self) -> crate::retry::RetryConfig { - crate::retry::RetryConfig::default() - } - /// Generate a complete (non-streaming) response. async fn do_generate(&self, options: &CallOptions) -> Result; diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index 71979d609..d8a882d94 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -125,11 +125,8 @@ impl MoaModel { let child_opts = ref_opts.for_step(format!("moa.ref[{i}]:{}/{}", m.provider(), m.model_id())); async move { - let retries = retry::prepare_retries( - child_opts.max_retries, - m.retry_config(), - child_opts.abort_signal.clone(), - ); + let retries = + retry::prepare_retries(child_opts.max_retries, child_opts.abort_signal.clone()); retries .retry(|| { if let Some(context) = &child_opts.recording_context { @@ -183,15 +180,6 @@ impl LanguageModel for MoaModel { &self.config.model_id } - fn retry_config(&self) -> retry::RetryConfig { - // Retrying the composite would rerun the entire reference fanout; - // references and aggregator are retried independently instead. - retry::RetryConfig { - max_retries: 0, - ..retry::RetryConfig::default() - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { // 1. Fan out references (non-streaming). let (texts, ref_usage, warnings) = self.run_references_nonstream(options).await?; @@ -206,11 +194,7 @@ impl LanguageModel for MoaModel { agg_opts.prompt = agg_prompt; // 3. Run the aggregator. - let retries = retry::prepare_retries( - agg_opts.max_retries, - self.aggregator.retry_config(), - agg_opts.abort_signal.clone(), - ); + let retries = retry::prepare_retries(agg_opts.max_retries, agg_opts.abort_signal.clone()); let agg_opts = agg_opts.for_step(format!( "moa.aggregator:{}/{}", self.aggregator.provider(), @@ -248,11 +232,7 @@ impl LanguageModel for MoaModel { // 3. Aggregator streams; we emit our own StreamStart and add reference // usage onto its Finish. We swallow the aggregator's StreamStart // (we've already emitted ours). - let retries = retry::prepare_retries( - agg_opts.max_retries, - self.aggregator.retry_config(), - agg_opts.abort_signal.clone(), - ); + let retries = retry::prepare_retries(agg_opts.max_retries, agg_opts.abort_signal.clone()); let agg_opts = agg_opts.for_step(format!( "moa.aggregator:{}/{}", self.aggregator.provider(), @@ -566,7 +546,6 @@ mod tests { let (ref_b, ref_b_calls) = retry_child("ref-b", "B", 0); let (aggregator, aggregator_calls) = retry_child("aggregator", "final", 1); let moa = MoaModel::new(vec![ref_a, ref_b], aggregator, MoaConfig::default()); - assert_eq!(moa.retry_config().max_retries, 0); let result = crate::generate::generate_text( &moa, diff --git a/aimux-core/src/reranking_model.rs b/aimux-core/src/reranking_model.rs index a07c20514..6e271057f 100644 --- a/aimux-core/src/reranking_model.rs +++ b/aimux-core/src/reranking_model.rs @@ -135,10 +135,6 @@ pub trait RerankingModel: Send + Sync { /// Provider-specific model ID, e.g. `"rerank-english-v3.0"`. fn model_id(&self) -> &str; - fn retry_config(&self) -> crate::retry::RetryConfig { - crate::retry::RetryConfig::default() - } - /// Rerank a list of documents using the query. /// /// Naming: the `do_` prefix prevents accidental direct usage by users. @@ -159,11 +155,7 @@ pub async fn rerank( ) -> Result { let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?; let abort_signal = options.abort_signal.clone(); - let retries = retry::prepare_retries( - options.max_retries, - model.retry_config(), - abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, abort_signal.clone()); timeout::run( retries.retry(|| model.do_rerank(&options)), abort_signal.as_ref(), diff --git a/aimux-core/src/retry.rs b/aimux-core/src/retry.rs index dd97a1470..448cbbb4f 100644 --- a/aimux-core/src/retry.rs +++ b/aimux-core/src/retry.rs @@ -8,33 +8,12 @@ use rand::Rng; use crate::AbortSignal; use crate::error::{AiMuxError, RetryError, RetryErrorReason}; -const DEFAULT_MAX_RETRIES: u32 = 2; -const INITIAL_DELAY_MS: u64 = 2_000; -const BACKOFF_FACTOR: u32 = 2; - -/// Default retry settings for model operations. -/// -/// Per-call `max_retries` overrides only [`Self::max_retries`]; the configured -/// delay and backoff factor remain in effect. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RetryConfig { - /// Maximum number of retries after the initial attempt. - pub max_retries: u32, - /// Initial delay between attempts. - pub initial_delay: Duration, - /// Multiplier applied to the delay after each retry. - pub backoff_factor: u32, -} - -impl Default for RetryConfig { - fn default() -> Self { - Self { - max_retries: DEFAULT_MAX_RETRIES, - initial_delay: Duration::from_millis(INITIAL_DELAY_MS), - backoff_factor: BACKOFF_FACTOR, - } - } -} +/// Default maximum number of retries after the initial attempt. +pub const DEFAULT_MAX_RETRIES: u32 = 2; +/// Default delay before the first retry, in milliseconds. +pub const DEFAULT_INITIAL_DELAY_MS: u64 = 2_000; +/// Default multiplier applied to the delay after each retry. +pub const DEFAULT_BACKOFF_FACTOR: u32 = 2; /// A prepared operation retry function and its resolved retry count. /// @@ -49,21 +28,17 @@ pub struct PreparedRetries { abort_signal: Option, } -/// Bind retry settings, applying a per-call retry-count override. +/// Bind the call-level retry count, applying [`DEFAULT_MAX_RETRIES`] when the +/// caller gave none. The delay and backoff factor are the fixed +/// [`DEFAULT_INITIAL_DELAY_MS`] / [`DEFAULT_BACKOFF_FACTOR`] constants: retry +/// is a call-level concern with no provider-level settings. #[must_use] -pub fn prepare_retries( - max_retries: Option, - mut config: RetryConfig, - abort_signal: Option, -) -> PreparedRetries { - if let Some(max_retries) = max_retries { - config.max_retries = max_retries; - } +pub fn prepare_retries(max_retries: Option, abort: Option) -> PreparedRetries { PreparedRetries { - max_retries: config.max_retries, - initial_delay_ms: u64::try_from(config.initial_delay.as_millis()).unwrap_or(u64::MAX), - backoff_factor: u64::from(config.backoff_factor), - abort_signal, + max_retries: max_retries.unwrap_or(DEFAULT_MAX_RETRIES), + initial_delay_ms: DEFAULT_INITIAL_DELAY_MS, + backoff_factor: u64::from(DEFAULT_BACKOFF_FACTOR), + abort_signal: abort, } } @@ -299,14 +274,11 @@ mod tests { #[tokio::test] async fn prepared_retries_resolves_the_default_and_binds_abort() { - assert_eq!( - prepare_retries(None, RetryConfig::default(), None).max_retries, - 2 - ); + assert_eq!(prepare_retries(None, None).max_retries, 2); let signal = AbortSignal::new(); signal.abort(); - let retries = prepare_retries(Some(4), RetryConfig::default(), Some(signal)); + let retries = prepare_retries(Some(4), Some(signal)); let attempts = AtomicUsize::new(0); let error = retries @@ -323,20 +295,14 @@ mod tests { } #[test] - fn per_call_max_preserves_the_model_delay_and_backoff() { - let retries = prepare_retries( - Some(4), - RetryConfig { - max_retries: 7, - initial_delay: Duration::from_millis(123), - backoff_factor: 3, - }, - None, - ); + fn prepared_retries_use_the_fixed_delay_and_backoff() { + let retries = prepare_retries(Some(4), None); assert_eq!(retries.max_retries, 4); - assert_eq!(retries.initial_delay_ms, 123); - assert_eq!(retries.backoff_factor, 3); + assert_eq!(retries.initial_delay_ms, DEFAULT_INITIAL_DELAY_MS); + assert_eq!(retries.backoff_factor, u64::from(DEFAULT_BACKOFF_FACTOR)); + assert_eq!(prepare_retries(None, None).max_retries, DEFAULT_MAX_RETRIES); + assert_eq!(prepare_retries(Some(0), None).max_retries, 0); } #[tokio::test] diff --git a/aimux-core/src/router.rs b/aimux-core/src/router.rs index 3a0745d74..1687e69ab 100644 --- a/aimux-core/src/router.rs +++ b/aimux-core/src/router.rs @@ -177,11 +177,7 @@ impl RouterModel { if i == exclude { continue; } - let retries = retry::prepare_retries( - options.max_retries, - m.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); let child_options = options.for_step(self.step_label(i)); match retries .retry(|| { @@ -229,24 +225,11 @@ impl LanguageModel for RouterModel { &self.config.model_id } - fn retry_config(&self) -> retry::RetryConfig { - // Retrying the composite would rerun routing and previously attempted - // children; each child is retried at its own execution boundary. - retry::RetryConfig { - max_retries: 0, - ..retry::RetryConfig::default() - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let raw = self.router.route(&options.prompt, &self.models)?; let idx = self.check_index(raw, "router")?; let model = &self.models[idx]; - let retries = retry::prepare_retries( - options.max_retries, - model.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); let child_options = options.for_step(self.step_label(idx)); match retries .retry(|| { @@ -272,11 +255,7 @@ impl LanguageModel for RouterModel { let raw = self.router.route(&options.prompt, &self.models)?; let idx = self.check_index(raw, "router")?; let model = &self.models[idx]; - let retries = retry::prepare_retries( - options.max_retries, - model.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); let child_options = options.for_step(self.step_label(idx)); // Only setup can be retried. The returned stream is passed through, so // failures after setup never invoke another child or duplicate output. @@ -666,7 +645,6 @@ mod tests { FallbackPolicy::OnError, RouterConfig::default(), ); - assert_eq!(router.retry_config().max_retries, 0); let result = crate::generate::generate_text( &router, diff --git a/aimux-core/src/search_model.rs b/aimux-core/src/search_model.rs index cd03f7b10..32a303f47 100644 --- a/aimux-core/src/search_model.rs +++ b/aimux-core/src/search_model.rs @@ -150,10 +150,6 @@ pub trait SearchModel: Send + Sync { /// `"tavily-search"`; others accept endpoint-specific names). fn model_id(&self) -> &str; - fn retry_config(&self) -> crate::retry::RetryConfig { - crate::retry::RetryConfig::default() - } - /// Execute a search query and return results. /// /// Naming: the `do_` prefix prevents accidental direct usage by users. @@ -171,11 +167,7 @@ pub async fn search( ) -> Result { let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?; let abort_signal = options.abort_signal.clone(); - let retries = retry::prepare_retries( - options.max_retries, - model.retry_config(), - abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, abort_signal.clone()); timeout::run( retries.retry(|| model.do_search(&options)), abort_signal.as_ref(), diff --git a/aimux-core/src/speech_model.rs b/aimux-core/src/speech_model.rs index 5add06e97..9d96b1f6f 100644 --- a/aimux-core/src/speech_model.rs +++ b/aimux-core/src/speech_model.rs @@ -148,10 +148,6 @@ pub trait SpeechModel: Send + Sync { /// Provider-specific model ID, e.g. `"tts-1"`. fn model_id(&self) -> &str; - fn retry_config(&self) -> crate::retry::RetryConfig { - crate::retry::RetryConfig::default() - } - /// Generate speech audio from text. /// /// Naming: the `do_` prefix prevents accidental direct usage by users. @@ -169,11 +165,7 @@ pub async fn generate_speech( ) -> Result { let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?; let abort_signal = options.abort_signal.clone(); - let retries = retry::prepare_retries( - options.max_retries, - model.retry_config(), - abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, abort_signal.clone()); timeout::run( retries.retry(|| model.do_generate(&options)), abort_signal.as_ref(), diff --git a/aimux-core/src/trace/layer.rs b/aimux-core/src/trace/layer.rs index a5b3b6e37..5e2cac10c 100644 --- a/aimux-core/src/trace/layer.rs +++ b/aimux-core/src/trace/layer.rs @@ -517,10 +517,6 @@ impl LanguageModel for TraceLayer { self.inner.model_id() } - fn retry_config(&self) -> crate::retry::RetryConfig { - self.inner.retry_config() - } - /// RFC-0023 §3.3: transparent decorators must forward the inner snapshot /// (otherwise recording sees the decorator's minimal record). fn config_snapshot(&self) -> crate::recording::ProviderRecord { diff --git a/aimux-core/src/transcription_model.rs b/aimux-core/src/transcription_model.rs index efeb2c75b..497dbe31f 100644 --- a/aimux-core/src/transcription_model.rs +++ b/aimux-core/src/transcription_model.rs @@ -295,10 +295,6 @@ pub trait TranscriptionModel: Send + Sync { /// Provider-specific model ID, e.g. `"whisper-1"`. fn model_id(&self) -> &str; - fn retry_config(&self) -> crate::retry::RetryConfig { - crate::retry::RetryConfig::default() - } - /// Generate a transcript. /// /// Naming: the `do_` prefix prevents accidental direct usage by users. @@ -334,11 +330,7 @@ pub async fn transcribe( ) -> Result { let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?; let abort_signal = options.abort_signal.clone(); - let retries = retry::prepare_retries( - options.max_retries, - model.retry_config(), - abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, abort_signal.clone()); timeout::run( retries.retry(|| model.do_generate(&options)), abort_signal.as_ref(), diff --git a/aimux-core/src/video_model.rs b/aimux-core/src/video_model.rs index 8833c4eed..5b80c5d9f 100644 --- a/aimux-core/src/video_model.rs +++ b/aimux-core/src/video_model.rs @@ -298,10 +298,6 @@ pub trait VideoModel: Send + Sync { /// Provider-specific model ID, e.g. `"kling-video"`. fn model_id(&self) -> &str; - fn retry_config(&self) -> retry::RetryConfig { - retry::RetryConfig::default() - } - /// Poll pacing for this model. Defaults to the AI SDK values; providers /// with configurable polling should surface their configuration here. fn poll_config(&self) -> VideoPollConfig { @@ -381,11 +377,7 @@ async fn start_and_poll( } let abort_signal = options.abort_signal.clone(); - let retries = retry::prepare_retries( - options.max_retries, - model.retry_config(), - abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, abort_signal.clone()); let mut poll = model.poll_config(); if let Some(overrides) = options.poll { @@ -604,13 +596,6 @@ mod tests { "scripted" } - fn retry_config(&self) -> crate::retry::RetryConfig { - crate::retry::RetryConfig { - initial_delay: Duration::from_millis(1), - ..crate::retry::RetryConfig::default() - } - } - fn max_videos_per_call(&self) -> Option { Some(1) } @@ -725,7 +710,8 @@ mod tests { assert_eq!(result.videos.len(), 1); } - #[tokio::test] + // Paused time: the fixed 2s retry delay must not make the test wait. + #[tokio::test(start_paused = true)] async fn start_retry_reuses_one_idempotency_key() { let model = ScriptedVideoModel::new(vec![StatusStep::Complete]).with_start_failures(1); let mut options = fast_poll_options(); @@ -923,7 +909,7 @@ mod tests { assert_eq!(unique.len(), 3, "one key per batch: {keys:?}"); } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn caller_key_is_distinct_per_batch_and_stable_across_retries_and_replays() { let model = ScriptedVideoModel::new(vec![]).with_start_failures(1); let mut options = fast_poll_options(); diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs index 8df2b9aad..fbf3f8861 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -158,6 +158,20 @@ fn error_value_snapshots_plain_variants() { AiMuxError::UnsupportedFunctionality("no audio".into()), r#"{"UnsupportedFunctionality":"no audio"}"#, ), + ( + AiMuxError::LoadApiKey { + env_var: "OPENAI_API_KEY".into(), + description: "OpenAI".into(), + }, + r#"{"LoadApiKey":{"env_var":"OPENAI_API_KEY","description":"OpenAI"}}"#, + ), + ( + AiMuxError::LoadSetting { + env_var: "AWS_REGION".into(), + name: "region".into(), + }, + r#"{"LoadSetting":{"env_var":"AWS_REGION","name":"region"}}"#, + ), ( AiMuxError::NoSuchModel { model_id: "gpt-9".into(), @@ -186,7 +200,7 @@ fn error_value_snapshots_plain_variants() { } /// The variant set is a cross-language contract of its own: bindings switch on -/// the wire tag. Adding or removing one is a breaking change (16 variants — +/// the wire tag. Adding or removing one is a breaking change (18 variants — /// the per-status avatars `Auth`/`ModelNotFound`/`RateLimited` are gone, /// `Http`/`Provider` folded into `ApiCall` (a failed exchange is an `ApiCall` /// error classified by `status_code`, transport failures included), and the @@ -207,6 +221,8 @@ fn variant_addition_breaks_this_match(error: &AiMuxError) { | AiMuxError::ToolCallRepair { .. } | AiMuxError::InvalidArgument(_) | AiMuxError::InvalidPrompt(_) + | AiMuxError::LoadApiKey { .. } + | AiMuxError::LoadSetting { .. } | AiMuxError::TokenExpired(_) | AiMuxError::UnsupportedFunctionality(_) | AiMuxError::NoSuchModel { .. } @@ -218,7 +234,7 @@ fn variant_addition_breaks_this_match(error: &AiMuxError) { } #[test] -fn variant_set_is_exactly_sixteen() { +fn variant_set_is_exactly_eighteen() { let all = [ AiMuxError::ApiCall(Box::new(api_error("x"))), AiMuxError::Retry(RetryError { @@ -247,6 +263,14 @@ fn variant_set_is_exactly_sixteen() { }, AiMuxError::InvalidArgument("x".into()), AiMuxError::InvalidPrompt("x".into()), + AiMuxError::LoadApiKey { + env_var: "X_API_KEY".into(), + description: "x".into(), + }, + AiMuxError::LoadSetting { + env_var: "X_REGION".into(), + name: "region".into(), + }, AiMuxError::TokenExpired("x".into()), AiMuxError::UnsupportedFunctionality("x".into()), AiMuxError::NoSuchModel { @@ -281,6 +305,8 @@ fn variant_set_is_exactly_sixteen() { "InvalidResponseData", "InvalidToolInput", "JsonParse", + "LoadApiKey", + "LoadSetting", "NoSuchModel", "NoSuchProvider", "NoSuchTool", diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index 72cdd91be..b557c14f6 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -474,6 +474,10 @@ fn aimux_error_code_of(err: &AiMuxError) -> i32 { AiMuxError::ToolCallRepair { .. } => AIMUX_E_TOOL_CALL_REPAIR, AiMuxError::InvalidArgument(_) => AIMUX_E_INVALID_ARGUMENT, AiMuxError::InvalidPrompt(_) => AIMUX_E_INVALID_PROMPT, + // TODO(A5): dedicated AIMUX_E_LOAD_API_KEY / AIMUX_E_LOAD_SETTING codes + // (+ env_var/description getters). Until then the new credential and + // setting errors surface as the existing invalid-argument code. + AiMuxError::LoadApiKey { .. } | AiMuxError::LoadSetting { .. } => AIMUX_E_INVALID_ARGUMENT, AiMuxError::TokenExpired(_) => AIMUX_E_TOKEN_EXPIRED, AiMuxError::UnsupportedFunctionality(_) => AIMUX_E_UNSUPPORTED_FUNCTIONALITY, AiMuxError::NoSuchModel { .. } => AIMUX_E_NO_SUCH_MODEL, diff --git a/aimux-ffi/tests/exports_smoke_test.rs b/aimux-ffi/tests/exports_smoke_test.rs index 528ef0954..620b9252f 100644 --- a/aimux-ffi/tests/exports_smoke_test.rs +++ b/aimux-ffi/tests/exports_smoke_test.rs @@ -24,7 +24,7 @@ //! - Every returned error and every returned JSON string is released //! (`aimux_error_free` / `aimux_free_string`). //! -//! Note: multimodal session smoke calls use the default RetryConfig +//! Note: multimodal session smoke calls use the default retry settings //! (2 retries) against a refused port; under `--test-threads=1` this adds //! roughly a minute of backoff sleep — run the default parallel profile. //! diff --git a/aimux-provider-utils/Cargo.toml b/aimux-provider-utils/Cargo.toml index bf427a093..fb2f67265 100644 --- a/aimux-provider-utils/Cargo.toml +++ b/aimux-provider-utils/Cargo.toml @@ -12,6 +12,7 @@ documentation = "https://docs.rs/aimux-provider-utils" aimux-core = { workspace = true } aimux-stream = { workspace = true } +async-trait = { workspace = true } reqwest = { workspace = true } http = "1" serde = { workspace = true } @@ -25,6 +26,9 @@ futures = { workspace = true } async-stream = { workspace = true } url = { workspace = true } chrono = "0.4" +sha2 = "0.10" +hmac = "0.12" +hex = "0.4" # WebSocket client (RFC-0028 realtime transcription). Optional: enabled via # the `ws` feature so consumers without realtime needs compile it out. diff --git a/aimux-provider-utils/src/api_key.rs b/aimux-provider-utils/src/api_key.rs index 6257efa36..5ac0488f5 100644 --- a/aimux-provider-utils/src/api_key.rs +++ b/aimux-provider-utils/src/api_key.rs @@ -1,32 +1,62 @@ -//! API key loading (from parameter or environment variable). +//! API key and setting loading (from a parameter or an environment variable). use aimux_core::AiMuxError; /// Load an API key from the given value or environment variable. /// -/// If `api_key` is `Some`, returns it directly. -/// Otherwise reads from `environment_variable_name`. +/// A supplied value is returned exactly as given — including the empty +/// string. An explicit key never falls back to the environment: a caller that +/// deliberately passes `""` (a keyless local server) must not have a +/// different credential substituted from the process environment. Only +/// `None` reads `environment_variable_name`. /// /// # Errors /// -/// Returns `AiMuxError::InvalidArgument` when neither the `api_key` value nor -/// the environment variable yields a key. +/// Returns `AiMuxError::LoadApiKey` when `api_key` is `None` and the +/// environment variable is unset. pub fn load_api_key( api_key: Option<&str>, environment_variable_name: &str, description: &str, ) -> Result { - if let Some(key) = api_key - && !key.is_empty() - { + if let Some(key) = api_key { return Ok(key.to_string()); } - std::env::var(environment_variable_name).map_err(|_| { - AiMuxError::InvalidArgument(format!( - "No API key found for {description}. \ - Please provide it via the `api_key` parameter \ - or set the `{environment_variable_name}` environment variable." - )) + std::env::var(environment_variable_name).map_err(|_| AiMuxError::LoadApiKey { + env_var: environment_variable_name.to_string(), + description: description.to_string(), }) } + +/// Load a required setting from the given value or environment variable, +/// with the same no-fallback rule for an explicit value as [`load_api_key`]. +/// +/// # Errors +/// +/// Returns `AiMuxError::LoadSetting` when `value` is `None` and the +/// environment variable is unset. +pub fn load_setting( + value: Option<&str>, + environment_variable_name: &str, + setting_name: &str, +) -> Result { + load_optional_setting(value, environment_variable_name).ok_or_else(|| AiMuxError::LoadSetting { + env_var: environment_variable_name.to_string(), + name: setting_name.to_string(), + }) +} + +/// Load an optional setting from the given value or environment variable. +/// An explicit value (even `""`) wins; `None` reads the environment; an unset +/// variable yields `None`. +#[must_use] +pub fn load_optional_setting( + value: Option<&str>, + environment_variable_name: &str, +) -> Option { + match value { + Some(value) => Some(value.to_string()), + None => std::env::var(environment_variable_name).ok(), + } +} diff --git a/aimux-provider-utils/src/extract_response_headers.rs b/aimux-provider-utils/src/extract_response_headers.rs index c82435746..ce32d356f 100644 --- a/aimux-provider-utils/src/extract_response_headers.rs +++ b/aimux-provider-utils/src/extract_response_headers.rs @@ -5,9 +5,7 @@ use std::collections::HashMap; /// Extract response headers as ordered pairs while redacting sensitive values. /// Recording and public error context share this one policy. #[must_use] -pub fn extract_response_header_pairs( - headers: &reqwest::header::HeaderMap, -) -> Vec<(String, String)> { +pub fn extract_response_header_pairs(headers: &http::HeaderMap) -> Vec<(String, String)> { headers .iter() .map(|(name, value)| { @@ -23,6 +21,6 @@ pub fn extract_response_header_pairs( /// Extract response headers while redacting sensitive values. #[must_use] -pub fn extract_response_headers(headers: &reqwest::header::HeaderMap) -> HashMap { +pub fn extract_response_headers(headers: &http::HeaderMap) -> HashMap { extract_response_header_pairs(headers).into_iter().collect() } diff --git a/aimux-provider-utils/src/fetch.rs b/aimux-provider-utils/src/fetch.rs new file mode 100644 index 000000000..df5d81ecf --- /dev/null +++ b/aimux-provider-utils/src/fetch.rs @@ -0,0 +1,441 @@ +//! Pluggable HTTP transport: the Rust shape of the AI SDK's `FetchFunction`. +//! +//! Every provider exchange ends in one [`Fetch::fetch`] call. A provider (or +//! its settings) may carry a [`FetchFunction`] — a mock, a signing decorator +//! such as [`crate::sigv4_fetch::SigV4Fetch`], a proxy-aware client — and +//! when none is set the exchange uses [`default_fetch`], resolved *per +//! request* (never frozen when a provider is created), so a process-wide +//! default can change after a provider exists. +//! +//! This module owns the real network leaf: [`ReqwestFetch`] (one shared +//! connection pool per tokio runtime) and [`PinnedFetch`] (a single +//! non-redirecting hop connected only to pre-validated addresses, used by the +//! SSRF download guard). Recording, response handlers and retry stay above +//! this layer, in `http.rs` and `aimux-core`. + +use std::collections::HashMap; +use std::fmt; +use std::net::IpAddr; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use async_trait::async_trait; +use bytes::Bytes; +use futures::StreamExt; +use futures::stream::BoxStream; +use http::{HeaderMap, Method, StatusCode}; +use reqwest::Client; +use url::Url; + +use aimux_core::AbortSignal; + +use crate::http::{ProxyConfig, global_proxy}; + +/// How a transport treats `3xx` responses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RedirectPolicy { + /// Follow redirects inside the transport (the ordinary API-call path). + #[default] + Follow, + /// Return the `3xx` response as-is. The caller follows redirects itself + /// and re-validates every hop (the SSRF download guard). + Manual, +} + +/// One outbound HTTP request, fully materialised: body bytes are final +/// (multipart already encoded), so a decorator can sign or inspect exactly +/// what goes on the wire. +#[derive(Debug, Clone)] +pub struct FetchRequest { + pub method: Method, + pub url: Url, + pub headers: HeaderMap, + pub body: Bytes, + pub redirect: RedirectPolicy, + /// Caller cancellation. The helper layer already drops the in-flight + /// future when this fires; transports may additionally use it to cancel + /// work they spawned. + pub signal: Option, + /// Whole-exchange bound, when the caller wants the transport itself to + /// enforce one. The helper layer enforces its own response deadline and + /// leaves this `None`. + pub timeout: Option, +} + +impl FetchRequest { + /// A request with no headers, no body, redirects followed and no bounds. + #[must_use] + pub fn new(method: Method, url: Url) -> Self { + Self { + method, + url, + headers: HeaderMap::new(), + body: Bytes::new(), + redirect: RedirectPolicy::Follow, + signal: None, + timeout: None, + } + } +} + +/// A response whose body has not been read yet. +pub struct FetchResponse { + pub status: StatusCode, + pub headers: HeaderMap, + /// The final URL (after any redirects the transport followed). + pub url: Url, + pub body: BoxStream<'static, Result>, +} + +impl FetchResponse { + /// A response whose whole body is already in memory (mocks, tests). + #[must_use] + pub fn from_bytes(status: StatusCode, headers: HeaderMap, url: Url, body: Bytes) -> Self { + Self { + status, + headers, + url, + body: futures::stream::once(async move { Ok(body) }).boxed(), + } + } + + /// The HTTP status. + #[must_use] + pub fn status(&self) -> StatusCode { + self.status + } + + /// The response headers. + #[must_use] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + /// The declared `Content-Length`, when present and well-formed. + #[must_use] + pub fn content_length(&self) -> Option { + self.headers + .get(http::header::CONTENT_LENGTH)? + .to_str() + .ok()? + .parse() + .ok() + } + + /// Consume the response into its body stream. + #[must_use] + pub fn bytes_stream(self) -> BoxStream<'static, Result> { + self.body + } +} + +impl fmt::Debug for FetchResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FetchResponse") + .field("status", &self.status) + .field("headers", &self.headers) + .field("url", &self.url.as_str()) + .finish_non_exhaustive() + } +} + +/// A transport-level failure: no HTTP response was produced, or the body +/// stream broke. HTTP error statuses are *not* errors here — they come back +/// as a [`FetchResponse`] and are interpreted by the response handlers. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum FetchError { + /// The connection could not be established. + #[error("{0}")] + Connect(String), + /// The transport's own timeout fired. + #[error("request timed out")] + Timeout, + /// The caller's [`AbortSignal`] fired. + #[error("request aborted")] + Aborted, + /// The connection failed while sending the request or reading the body. + #[error("{0}")] + Io(String), + /// Anything else, including requests the transport refuses to build. + #[error("{0}")] + Other(String), +} + +impl FetchError { + /// Whether an operation retry may help. Connection, timeout and I/O + /// failures are transient; an abort is the caller's decision and `Other` + /// (a malformed request, a broken transport) will fail the same way again. + #[must_use] + pub fn is_retryable(&self) -> bool { + matches!(self, Self::Connect(_) | Self::Timeout | Self::Io(_)) + } +} + +/// The transport contract: one request in, one response out. +#[async_trait] +pub trait Fetch: Send + Sync + 'static { + /// Perform one HTTP exchange. + /// + /// # Errors + /// + /// Returns a [`FetchError`] when no response could be produced. + async fn fetch(&self, request: FetchRequest) -> Result; +} + +impl fmt::Debug for dyn Fetch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("dyn Fetch") + } +} + +/// A shareable transport, as carried by provider settings and requests. +pub type FetchFunction = Arc; + +/// The process-wide default transport, resolved at request time. +#[must_use] +pub fn default_fetch() -> FetchFunction { + static DEFAULT: OnceLock = OnceLock::new(); + DEFAULT.get_or_init(|| Arc::new(ReqwestFetch)).clone() +} + +/// The default transport: reqwest over rustls, with a connection pool shared +/// by every `ReqwestFetch` in the process (one pool per tokio runtime). +/// +/// The pool has a connect timeout but no client-wide response timeout: it +/// also serves streaming exchanges, so the non-streaming response bound lives +/// per exchange in the helper layer and `aimux-core` owns the operation +/// deadline. The process-wide [`ProxyConfig`] is snapshotted when a runtime's +/// client is first built. +#[derive(Debug, Clone, Copy, Default)] +pub struct ReqwestFetch; + +#[async_trait] +impl Fetch for ReqwestFetch { + async fn fetch(&self, request: FetchRequest) -> Result { + if request.redirect == RedirectPolicy::Manual { + // The pooled client follows redirects; a manual hop needs a + // non-redirecting client. + return PinnedFetch::unpinned().fetch(request).await; + } + let client = shared_client()?; + send(&client, request).await + } +} + +/// One non-redirecting hop of a validated download. The connection is pinned +/// to exactly the DNS answers that passed the SSRF guard (resolve overrides), +/// defeating TTL-0 rebinding; [`PinnedFetch::unpinned`] is the hop on the +/// trusted origin, which connects normally but still never follows a +/// redirect. The proxy configuration is applied so reqwest makes the per-URL +/// routing decision itself: when a proxy carries the request the proxy +/// resolves the target (a trusted transport, the override is unused), and any +/// request the proxy rules send DIRECT still connects only through the +/// validated, pinned addresses. +/// +/// A client is built per hop: downloads are rare and each hop is one +/// request, so there is no pool to share — and no stale-runtime pool to +/// reuse. Always behaves as [`RedirectPolicy::Manual`]. +#[derive(Debug, Clone, Default)] +pub struct PinnedFetch { + addresses: Vec, +} + +impl PinnedFetch { + /// Pin the connection to these validated addresses. + #[must_use] + pub fn new(addresses: Vec) -> Self { + Self { addresses } + } + + /// No pinning: the hop targets the trusted origin. + #[must_use] + pub fn unpinned() -> Self { + Self::default() + } + + fn client(&self, url: &Url) -> Result { + let builder = reqwest::Client::builder() + .connect_timeout(Duration::from_millis(10_000)) + .redirect(reqwest::redirect::Policy::none()); + let builder = apply_proxy(builder, &global_proxy()); + if self.addresses.is_empty() { + return builder.build().map_err(|error| { + FetchError::Other(format!("download client initialization failed: {error}")) + }); + } + let host = url + .host_str() + .ok_or_else(|| FetchError::Other("download url has no host".to_string()))?; + let port = url.port_or_known_default().unwrap_or(80); + let socket_addresses: Vec<_> = self + .addresses + .iter() + .map(|address| std::net::SocketAddr::new(*address, port)) + .collect(); + builder + .resolve_to_addrs(host, &socket_addresses) + .build() + .map_err(|error| { + FetchError::Other(format!( + "pinned download client initialization failed: {error}" + )) + }) + } +} + +#[async_trait] +impl Fetch for PinnedFetch { + async fn fetch(&self, request: FetchRequest) -> Result { + let client = self.client(&request.url)?; + send(&client, request).await + } +} + +async fn send(client: &Client, request: FetchRequest) -> Result { + let mut builder = client + .request(request.method, request.url) + .headers(request.headers); + if !request.body.is_empty() { + builder = builder.body(request.body); + } + if let Some(timeout) = request.timeout { + builder = builder.timeout(timeout); + } + let response = builder.send().await.map_err(map_reqwest_error)?; + let status = response.status(); + let headers = response.headers().clone(); + let url = response.url().clone(); + Ok(FetchResponse { + status, + headers, + url, + body: response + .bytes_stream() + .map(|chunk| chunk.map_err(map_reqwest_error)) + .boxed(), + }) +} + +fn map_reqwest_error(error: reqwest::Error) -> FetchError { + if error.is_builder() { + FetchError::Other(error.to_string()) + } else if error.is_connect() { + FetchError::Connect(error.to_string()) + } else if error.is_timeout() { + FetchError::Timeout + } else { + FetchError::Io(error.to_string()) + } +} + +// One client (and so one connection pool) PER RUNTIME, not per process: +// pooled connections are driven by tasks spawned onto the runtime that +// made the request, so when that runtime shuts down its pooled +// connections become unusable while staying checked in — and the OS can +// recycle their ports to fresh servers, handing later requests a dead +// connection. Production processes run a single runtime and still get +// exactly one client. Runtimes leave no drop signal, so dead entries are +// undetectable; the map is instead bounded by `SHARED_CLIENT_CAP` — see +// `shared_client`. +static SHARED: OnceLock, Client>>> = OnceLock::new(); + +// Hosts that churn short-lived runtimes (a test binary, an FFI embedder +// creating a runtime per call) would otherwise grow the map without bound. +// Eviction is safe: correctness only requires never *reusing* a dead +// runtime's pool, and an evicted live runtime simply rebuilds a fresh +// client on its next request. +const SHARED_CLIENT_CAP: usize = 8; + +/// Return the shared client for the current runtime, building it on first use. +fn shared_client() -> Result { + let key = tokio::runtime::Handle::try_current().ok().map(|h| h.id()); + let mut clients = SHARED + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .expect("shared HTTP client mutex poisoned"); + if let Some(client) = clients.get(&key) { + return Ok(client.clone()); + } + let client = build_client(global_proxy()).map_err(|error| { + FetchError::Other(format!("shared HTTP client initialization failed: {error}")) + })?; + if clients.len() >= SHARED_CLIENT_CAP { + // Which entries are dead is unknowable, so evict them all; in-flight + // requests hold their own `Client` clone and are unaffected. + clients.clear(); + } + clients.insert(key, client.clone()); + Ok(client) +} + +fn build_client(proxy: ProxyConfig) -> Result { + let builder = reqwest::Client::builder() + .connect_timeout(Duration::from_millis(10_000)) + .pool_max_idle_per_host(10) + .pool_idle_timeout(Some(Duration::from_secs(30))) + .tcp_keepalive(Some(Duration::from_secs(20))); + apply_proxy(builder, &proxy) + .build() + .map_err(|error| error.to_string()) +} + +fn apply_proxy(mut builder: reqwest::ClientBuilder, proxy: &ProxyConfig) -> reqwest::ClientBuilder { + let http = proxy.http_url.as_deref().or(proxy.all_url.as_deref()); + let https = proxy.https_url.as_deref().or(proxy.all_url.as_deref()); + if let Some(url) = http + && let Ok(reqwest_proxy) = reqwest::Proxy::http(url) + { + builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy); + } + if let Some(url) = https + && let Ok(reqwest_proxy) = reqwest::Proxy::https(url) + { + builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy); + } + builder +} + +// Kept separate so both proxy schemes use precisely the same no-proxy rule. +fn apply_no_proxy( + builder: reqwest::ClientBuilder, + proxy: reqwest::Proxy, + no_proxy: &Option, +) -> reqwest::ClientBuilder { + match no_proxy.as_deref().map(reqwest::NoProxy::from_string) { + Some(Some(no_proxy)) => builder.proxy(proxy.no_proxy(Some(no_proxy))), + _ => builder.proxy(proxy), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retryability_follows_the_failure_kind() { + assert!(FetchError::Connect("refused".into()).is_retryable()); + assert!(FetchError::Timeout.is_retryable()); + assert!(FetchError::Io("reset".into()).is_retryable()); + assert!(!FetchError::Aborted.is_retryable()); + assert!(!FetchError::Other("bad request".into()).is_retryable()); + } + + #[tokio::test] + async fn from_bytes_serves_the_body_once() { + let url = Url::parse("https://example.test/").unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_LENGTH, "5".parse().unwrap()); + let response = + FetchResponse::from_bytes(StatusCode::OK, headers, url, Bytes::from("hello")); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.content_length(), Some(5)); + let chunks: Vec<_> = response.bytes_stream().collect().await; + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].as_ref().unwrap(), &Bytes::from("hello")); + } + + #[test] + fn default_fetch_is_a_single_process_wide_leaf() { + assert!(Arc::ptr_eq(&default_fetch(), &default_fetch())); + } +} diff --git a/aimux-provider-utils/src/handle_fetch_error.rs b/aimux-provider-utils/src/handle_fetch_error.rs index 4a935e327..5f37273d9 100644 --- a/aimux-provider-utils/src/handle_fetch_error.rs +++ b/aimux-provider-utils/src/handle_fetch_error.rs @@ -1,6 +1,8 @@ //! Fetch error normalization. -use aimux_core::AiMuxError; +use aimux_core::{AiMuxError, ApiCallError}; + +use crate::fetch::FetchError; /// Attach request context to transport failures without changing other errors. #[must_use] @@ -19,3 +21,60 @@ pub fn handle_fetch_error( other => other, } } + +/// Convert a transport failure into the public error model: an abort stays an +/// abort; everything else is an `ApiCall` error with no status (no response +/// arrived), retryable exactly when [`FetchError::is_retryable`] says so. +#[must_use] +pub fn fetch_error_to_ai_mux_error( + error: FetchError, + url: &str, + request_body_values: &serde_json::Value, +) -> AiMuxError { + match error { + FetchError::Aborted => AiMuxError::Aborted("request aborted".into()), + other => AiMuxError::ApiCall(Box::new(ApiCallError { + is_retryable: other.is_retryable(), + ..ApiCallError::new(other.to_string(), url, request_body_values.clone()) + })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn convert(error: FetchError) -> AiMuxError { + fetch_error_to_ai_mux_error(error, "https://example.test/v1", &serde_json::json!({})) + } + + #[test] + fn transport_failures_become_retryable_api_calls_without_a_status() { + for error in [ + FetchError::Connect("refused".into()), + FetchError::Timeout, + FetchError::Io("reset".into()), + ] { + match convert(error) { + AiMuxError::ApiCall(detail) => { + assert!(detail.is_retryable); + assert_eq!(detail.status_code, None); + assert_eq!(detail.url, "https://example.test/v1"); + } + other => panic!("expected ApiCall, got {other:?}"), + } + } + } + + #[test] + fn other_failures_are_not_retried_and_aborts_stay_aborts() { + match convert(FetchError::Other("bad".into())) { + AiMuxError::ApiCall(detail) => assert!(!detail.is_retryable), + other => panic!("expected ApiCall, got {other:?}"), + } + assert!(matches!( + convert(FetchError::Aborted), + AiMuxError::Aborted(_) + )); + } +} diff --git a/aimux-provider-utils/src/headers.rs b/aimux-provider-utils/src/headers.rs index 96822c73b..1d7cc3f26 100644 --- a/aimux-provider-utils/src/headers.rs +++ b/aimux-provider-utils/src/headers.rs @@ -1,10 +1,22 @@ //! HTTP header utilities. +//! +//! Provider headers are layered — provider settings, then per-call headers, +//! then authentication — and later layers win. A layer entry of `None` means +//! "remove this header" (the AI SDK's `undefined` header value). use std::collections::HashMap; +use crate::resolvable::Resolvable; + /// The SDK version (injected at build time or hardcoded for now). const SDK_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// A header layer: `None` removes the header from earlier layers. +pub type HeaderMapOpt = HashMap>; + +/// Headers supplied directly or computed per request (token refresh, …). +pub type HeadersFn = Resolvable; + /// Add a `User-Agent` suffix to the headers. /// /// Pattern: `ai-sdk//` @@ -12,3 +24,113 @@ pub fn with_user_agent_suffix(headers: &mut HashMap, provider_na let ua = format!("ai-sdk/{provider_name}/{SDK_VERSION}"); headers.insert("User-Agent".to_string(), ua); } + +/// Merge header layers, earliest first. Names are lowercased; a later layer +/// overrides an earlier one (never appends), including with `None`, which +/// removes the header. Entries of one layer whose names differ only by case +/// are applied in byte order of the original name, so the result does not +/// depend on hash-map iteration order. +#[must_use] +pub fn combine_headers(layers: &[&HeaderMapOpt]) -> HeaderMapOpt { + let mut combined = HeaderMapOpt::new(); + for layer in layers { + let mut entries: Vec<_> = layer.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + for (name, value) in entries { + combined.insert(name.to_ascii_lowercase(), value.clone()); + } + } + combined +} + +/// Turn a header layer into the list that goes on the wire: names lowercased, +/// `None` entries dropped, one entry per name (when names differ only by case +/// the one that sorts last wins), sorted by name. +#[must_use] +pub fn normalize_headers(headers: HeaderMapOpt) -> Vec<(String, String)> { + let mut entries: Vec<_> = headers.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut unique: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (name, value) in entries { + unique.insert(name.to_ascii_lowercase(), value); + } + unique + .into_iter() + .filter_map(|(name, value)| value.map(|value| (name, value))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layer(entries: &[(&str, Option<&str>)]) -> HeaderMapOpt { + entries + .iter() + .map(|(name, value)| ((*name).to_string(), value.map(str::to_string))) + .collect() + } + + #[test] + fn names_are_lowercased_and_later_layers_override_regardless_of_case() { + let provider = layer(&[("X-Org", Some("provider")), ("Content-Type", Some("a"))]); + let call = layer(&[("x-org", Some("call"))]); + let combined = combine_headers(&[&provider, &call]); + assert_eq!(combined.len(), 2); + assert_eq!(combined["x-org"].as_deref(), Some("call")); + assert_eq!(combined["content-type"].as_deref(), Some("a")); + } + + #[test] + fn none_removes_a_header_set_by_an_earlier_layer() { + let provider = layer(&[("x-remove", Some("1")), ("x-keep", Some("1"))]); + let call = layer(&[("X-Remove", None)]); + let wire = normalize_headers(combine_headers(&[&provider, &call])); + assert_eq!(wire, vec![("x-keep".to_string(), "1".to_string())]); + } + + #[test] + fn a_later_value_restores_a_removed_header() { + let removed = layer(&[("x-a", None)]); + let restored = layer(&[("x-a", Some("back"))]); + let wire = normalize_headers(combine_headers(&[ + &layer(&[("x-a", Some("1"))]), + &removed, + &restored, + ])); + assert_eq!(wire, vec![("x-a".to_string(), "back".to_string())]); + } + + #[test] + fn provider_then_call_then_auth_order_with_a_single_authorization() { + let provider = layer(&[ + ("Authorization", Some("Bearer provider")), + ("x-p", Some("1")), + ]); + let call = layer(&[("authorization", Some("Bearer call")), ("x-c", Some("1"))]); + let auth = layer(&[("AUTHORIZATION", Some("Bearer auth"))]); + let wire = normalize_headers(combine_headers(&[&provider, &call, &auth])); + let authorizations: Vec<_> = wire + .iter() + .filter(|(name, _)| name == "authorization") + .collect(); + assert_eq!( + authorizations.len(), + 1, + "same name must be sent once: {wire:?}" + ); + assert_eq!(authorizations[0].1, "Bearer auth"); + assert!(wire.contains(&("x-p".to_string(), "1".to_string()))); + assert!(wire.contains(&("x-c".to_string(), "1".to_string()))); + } + + #[test] + fn normalize_collapses_names_that_differ_only_by_case_deterministically() { + let wire = normalize_headers(layer(&[("X-A", Some("upper")), ("x-a", Some("lower"))])); + assert_eq!(wire.len(), 1); + assert_eq!(wire[0].0, "x-a"); + // "x-a" sorts after "X-A" in byte order, so it is the one kept. + assert_eq!(wire[0].1, "lower"); + } +} diff --git a/aimux-provider-utils/src/http.rs b/aimux-provider-utils/src/http.rs index 397e78a70..c730278c4 100644 --- a/aimux-provider-utils/src/http.rs +++ b/aimux-provider-utils/src/http.rs @@ -1,18 +1,17 @@ -//! One shared HTTP client and one-exchange request transport. +//! One-exchange request transport. //! //! Retry and operation/stream timeouts belong to `aimux-core`. This module -//! sends exactly one request and leaves successful/failed body interpretation -//! to the response handlers selected by the provider. +//! sends exactly one request through a [`Fetch`] (the request's own, or the +//! process default) and leaves successful/failed body interpretation to the +//! response handlers selected by the provider. -use std::collections::HashMap; use std::pin::Pin; -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; use bytes::Bytes; use futures::{Stream, StreamExt, stream::BoxStream}; -use reqwest::Client; use serde::{Deserialize, Serialize}; use aimux_core::recording::{ @@ -20,9 +19,14 @@ use aimux_core::recording::{ }; use aimux_core::{AiMuxError, ApiCallError}; +use crate::fetch::{ + Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, PinnedFetch, RedirectPolicy, + default_fetch, +}; +use crate::handle_fetch_error::fetch_error_to_ai_mux_error; use crate::logging::{body_logging_enabled, redact_body}; -/// Process-wide proxy configuration, fixed before the shared client is built. +/// Process-wide proxy configuration, fixed before the first HTTP client is built. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ProxyConfig { pub http_url: Option, @@ -32,23 +36,6 @@ pub struct ProxyConfig { } static GLOBAL_PROXY: OnceLock = OnceLock::new(); -// One client (and so one connection pool) PER RUNTIME, not per process: -// pooled connections are driven by tasks spawned onto the runtime that -// made the request, so when that runtime shuts down its pooled -// connections become unusable while staying checked in — and the OS can -// recycle their ports to fresh servers, handing later requests a dead -// connection. Production processes run a single runtime and still get -// exactly one client. Runtimes leave no drop signal, so dead entries are -// undetectable; the map is instead bounded by `SHARED_CLIENT_CAP` — see -// `shared_client`. -static SHARED: OnceLock, Client>>> = OnceLock::new(); - -// Hosts that churn short-lived runtimes (a test binary, an FFI embedder -// creating a runtime per call) would otherwise grow the map without bound. -// Eviction is safe: correctness only requires never *reusing* a dead -// runtime's pool, and an evicted live runtime simply rebuilds a fresh -// client on its next request. -const SHARED_CLIENT_CAP: usize = 8; /// Set proxy configuration before the first HTTP operation. /// @@ -68,125 +55,9 @@ pub(crate) fn global_proxy() -> ProxyConfig { GLOBAL_PROXY.get().cloned().unwrap_or_default() } -/// Return the single shared client. It has a connect timeout but no -/// client-wide response timeout: the shared client also serves streaming -/// exchanges, so the 30s non-streaming response bound lives per-exchange in -/// `call_to_api`, and Core owns the operation deadline. -/// -/// # Errors -/// -/// Returns an initialization error if the shared client cannot be built. -pub fn shared_client() -> Result { - let key = tokio::runtime::Handle::try_current().ok().map(|h| h.id()); - let mut clients = SHARED - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .expect("shared HTTP client mutex poisoned"); - if let Some(client) = clients.get(&key) { - return Ok(client.clone()); - } - let client = build_client(global_proxy()).map_err(|error| { - AiMuxError::Other(format!("shared HTTP client initialization failed: {error}")) - })?; - if clients.len() >= SHARED_CLIENT_CAP { - // Which entries are dead is unknowable, so evict them all; in-flight - // requests hold their own `Client` clone and are unaffected. - clients.clear(); - } - clients.insert(key, client.clone()); - Ok(client) -} - -fn build_client(proxy: ProxyConfig) -> Result { - let builder = Client::builder() - .connect_timeout(Duration::from_millis(10_000)) - .pool_max_idle_per_host(10) - .pool_idle_timeout(Some(Duration::from_secs(30))) - .tcp_keepalive(Some(Duration::from_secs(20))); - apply_proxy(builder, &proxy) - .build() - .map_err(|error| error.to_string()) -} - /// Redirect ceiling for the manual validated-download loop. const MAX_REDIRECTS: usize = 10; -/// Non-redirecting client for a validated hop on the trusted origin. -/// Redirects are followed manually so every hop is re-validated; built per -/// exchange (downloads are rare and each hop is one request, so there is no -/// pool to share — and no stale-runtime pool to reuse). -fn download_client() -> Result { - let builder = Client::builder() - .connect_timeout(Duration::from_millis(10_000)) - .redirect(reqwest::redirect::Policy::none()); - apply_proxy(builder, &global_proxy()) - .build() - .map_err(|e| AiMuxError::Other(format!("download client initialization failed: {e}"))) -} - -/// Client for one validated hop off the trusted origin: the connection is -/// pinned to exactly the DNS answers that passed the guard (resolve -/// overrides), defeating TTL-0 rebinding. The proxy configuration is applied -/// so reqwest makes the per-URL routing decision itself: when a proxy -/// carries the request the proxy resolves the target (a trusted transport, -/// the override below is unused), and any request the proxy rules send -/// DIRECT still connects only through the validated, pinned addresses. -fn pinned_client(url: &str, addresses: &[std::net::IpAddr]) -> Result { - if addresses.is_empty() { - return Err(AiMuxError::Other( - "cannot build a pinned download client without an address".into(), - )); - } - let parsed = reqwest::Url::parse(url) - .map_err(|e| AiMuxError::Other(format!("invalid download url: {e}")))?; - let host = parsed - .host_str() - .ok_or_else(|| AiMuxError::Other("download url has no host".to_string()))? - .to_string(); - let port = parsed.port_or_known_default().unwrap_or(80); - let socket_addresses: Vec<_> = addresses - .iter() - .map(|address| std::net::SocketAddr::new(*address, port)) - .collect(); - let builder = Client::builder() - .connect_timeout(Duration::from_millis(10_000)) - .redirect(reqwest::redirect::Policy::none()); - apply_proxy(builder, &global_proxy()) - .resolve_to_addrs(&host, &socket_addresses) - .build() - .map_err(|e| { - AiMuxError::Other(format!("pinned download client initialization failed: {e}")) - }) -} - -fn apply_proxy(mut builder: reqwest::ClientBuilder, proxy: &ProxyConfig) -> reqwest::ClientBuilder { - let http = proxy.http_url.as_deref().or(proxy.all_url.as_deref()); - let https = proxy.https_url.as_deref().or(proxy.all_url.as_deref()); - if let Some(url) = http - && let Ok(reqwest_proxy) = reqwest::Proxy::http(url) - { - builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy); - } - if let Some(url) = https - && let Ok(reqwest_proxy) = reqwest::Proxy::https(url) - { - builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy); - } - builder -} - -// Kept separate so both proxy schemes use precisely the same no-proxy rule. -fn apply_no_proxy( - builder: reqwest::ClientBuilder, - proxy: reqwest::Proxy, - no_proxy: &Option, -) -> reqwest::ClientBuilder { - match no_proxy.as_deref().map(reqwest::NoProxy::from_string) { - Some(Some(no_proxy)) => builder.proxy(proxy.no_proxy(Some(no_proxy))), - _ => builder.proxy(proxy), - } -} - /// Explicit raw request body used only by `post_to_api`. #[derive(Debug, Clone)] pub enum HttpBody { @@ -248,6 +119,11 @@ pub struct HttpRequest { /// this value — from the first request and on every redirect hop. `None` /// means the caller gates its own headers (e.g. BFL's host allowlist). pub credentialed_origin: Option, + /// Transport for this exchange. `None` resolves [`default_fetch`] when the + /// request is sent. Ignored when `validate_url` is set: downloads of + /// provider-supplied URLs always use the SSRF guard's pinned transport, + /// never a provider-injected one. + pub fetch: Option, } /// The per-operation context every model's call options carry into an @@ -339,6 +215,7 @@ pub(crate) struct PreparedRequest { pub(crate) response_timeout: Option, pub(crate) max_json_response_bytes: Option, pub(crate) validation: Option, + pub(crate) fetch: Option, } impl HttpRequest { @@ -366,6 +243,7 @@ impl HttpRequest { trusted_origin: self.trusted_origin, credentialed_origin: self.credentialed_origin, }), + fetch: self.fetch, } } } @@ -430,7 +308,7 @@ impl Drop for ExchangeGuard<'_> { /// Execute exactly one HTTP exchange. Core owns retry and operation timeout. pub(crate) async fn send_request_once( request: &PreparedRequest, -) -> Result { +) -> Result { let started = Instant::now(); let (attempt, exchange_index) = request .recording_context @@ -459,12 +337,13 @@ pub(crate) async fn send_request_once( } let sent = if request.validation.is_some() { + // D26: the download transport is never provider-injected. send_validated_redirects(request).await } else { - match shared_client() { - Ok(client) => send_one_request(&client, request).await, - Err(error) => Err(error), - } + // Resolved per request, so a late change of the process default is + // honored by providers created before it. + let fetch = request.fetch.clone().unwrap_or_else(default_fetch); + send_one_request(fetch.as_ref(), request, RedirectPolicy::Follow).await }; let response = match sent { Ok(response) => response, @@ -477,7 +356,7 @@ pub(crate) async fn send_request_once( let latency_ms = started.elapsed().as_millis() as u64; tracing::debug!( target: "aimux_provider_utils::http", - status = response.status().as_u16(), + status = response.status.as_u16(), latency_ms, "response" ); @@ -492,51 +371,49 @@ pub(crate) async fn send_request_once( } async fn send_one_request( - client: &Client, + fetch: &dyn Fetch, request: &PreparedRequest, -) -> Result { - if let Some(signal) = &request.abort_signal { - if signal.is_aborted() { - return Err(AiMuxError::from_abort_signal(signal)); - } - tokio::select! { - biased; - () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)), - response = build_request_builder(client, request)?.send() => { - response.map_err(|error| AiMuxError::ApiCall(Box::new(ApiCallError { - is_retryable: true, - ..api_call_error(request, error.to_string()) - }))) + redirect: RedirectPolicy, +) -> Result { + let mut fetch_request = build_fetch_request(request, redirect)?; + fetch_request.signal = request.abort_signal.clone(); + let sent = match &request.abort_signal { + Some(signal) => { + if signal.is_aborted() { + return Err(AiMuxError::from_abort_signal(signal)); + } + tokio::select! { + biased; + () = signal.cancelled() => return Err(AiMuxError::from_abort_signal(signal)), + sent = fetch.fetch(fetch_request) => sent, } } - } else { - build_request_builder(client, request)? - .send() - .await - .map_err(|error| { - AiMuxError::ApiCall(Box::new(ApiCallError { - is_retryable: true, - ..api_call_error(request, error.to_string()) - })) - }) - } + None => fetch.fetch(fetch_request).await, + }; + sent.map_err(|error| { + fetch_error_to_ai_mux_error( + error, + &sanitized_request_url(&request.url), + &crate::logging::redact_request_values(&request.body), + ) + }) } -fn is_redirect_status(status: reqwest::StatusCode) -> bool { +fn is_redirect_status(status: http::StatusCode) -> bool { matches!( status, - reqwest::StatusCode::MOVED_PERMANENTLY - | reqwest::StatusCode::FOUND - | reqwest::StatusCode::SEE_OTHER - | reqwest::StatusCode::TEMPORARY_REDIRECT - | reqwest::StatusCode::PERMANENT_REDIRECT + http::StatusCode::MOVED_PERMANENTLY + | http::StatusCode::FOUND + | http::StatusCode::SEE_OTHER + | http::StatusCode::TEMPORARY_REDIRECT + | http::StatusCode::PERMANENT_REDIRECT ) } fn redirect_error( request: &PreparedRequest, message: impl Into, - status: reqwest::StatusCode, + status: http::StatusCode, ) -> AiMuxError { AiMuxError::ApiCall(Box::new(ApiCallError { status_code: Some(status.as_u16()), @@ -548,9 +425,7 @@ fn redirect_error( /// every hop is re-validated and its connection pinned to the DNS answers /// that passed the guard (see `download_guard`). The whole chain is one /// exchange to the caller, matching how an auto-following client behaves. -async fn send_validated_redirects( - request: &PreparedRequest, -) -> Result { +async fn send_validated_redirects(request: &PreparedRequest) -> Result { let validation = request .validation .clone() @@ -568,17 +443,13 @@ async fn send_validated_redirects( crate::download_guard::validate_download_target(¤t.url, trusted_origin).await?; for redirect_count in 0..=MAX_REDIRECTS { // Empty pins mean the hop is on the trusted origin. - let client = if pinned.is_empty() { - download_client()? - } else { - pinned_client(¤t.url, &pinned)? - }; - let response = send_one_request(&client, ¤t).await?; - let status = response.status(); + let hop = PinnedFetch::new(pinned.clone()); + let response = send_one_request(&hop, ¤t, RedirectPolicy::Manual).await?; + let status = response.status; if !is_redirect_status(status) { return Ok(response); } - let Some(location) = response.headers().get(reqwest::header::LOCATION) else { + let Some(location) = response.headers.get(http::header::LOCATION) else { return Ok(response); }; if redirect_count == MAX_REDIRECTS { @@ -614,10 +485,10 @@ async fn send_validated_redirects( if !crate::download_guard::same_origin(&next, credential_anchor) { crate::download_guard::retain_user_agent(&mut current.headers); } - if status == reqwest::StatusCode::SEE_OTHER + if status == http::StatusCode::SEE_OTHER || (matches!( status, - reqwest::StatusCode::MOVED_PERMANENTLY | reqwest::StatusCode::FOUND + http::StatusCode::MOVED_PERMANENTLY | http::StatusCode::FOUND ) && matches!(current.method, HttpMethod::Post)) { current.method = HttpMethod::Get; @@ -628,28 +499,57 @@ async fn send_validated_redirects( unreachable!("redirect loop returns on response or error") } -fn build_request_builder( - client: &Client, +/// Materialise one prepared request for a [`Fetch`]: validated URL, final body +/// bytes and an ordered-by-name header map. Headers are *inserted*, so a +/// later entry for the same (case-insensitive) name replaces an earlier one — +/// a request never goes out with duplicate `Authorization` headers. +fn build_fetch_request( request: &PreparedRequest, -) -> Result { - let mut builder = match request.method { - HttpMethod::Get => client.get(&request.url), - HttpMethod::Post => client.post(&request.url), - }; + redirect: RedirectPolicy, +) -> Result { + let url = url::Url::parse(&request.url) + .map_err(|error| AiMuxError::InvalidArgument(format!("invalid request URL: {error}")))?; + let mut headers = http::HeaderMap::new(); for (name, value) in &request.headers { - let header_name = reqwest::header::HeaderName::try_from(name) + let header_name = http::HeaderName::try_from(name) .map_err(|_| AiMuxError::InvalidArgument(format!("invalid header name: {name}")))?; - let header_value = reqwest::header::HeaderValue::try_from(value).map_err(|_| { + let header_value = http::HeaderValue::try_from(value).map_err(|_| { AiMuxError::InvalidArgument(format!("invalid header value for {name}: {value}")) })?; - builder = builder.header(header_name, header_value); + headers.insert(header_name, header_value); } - Ok(match &request.body { - HttpBody::Json(value) => builder.json(value), - HttpBody::Bytes(bytes, content_type) => builder - .header(reqwest::header::CONTENT_TYPE, content_type) - .body(bytes.clone()), - HttpBody::Empty => builder, + let body = match &request.body { + HttpBody::Json(value) => { + if !headers.contains_key(http::header::CONTENT_TYPE) { + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + } + Bytes::from(serde_json::to_vec(value).map_err(|error| { + AiMuxError::InvalidArgument(format!("request body is not serializable: {error}")) + })?) + } + HttpBody::Bytes(bytes, content_type) => { + let content_type = http::HeaderValue::try_from(content_type).map_err(|_| { + AiMuxError::InvalidArgument(format!("invalid content type: {content_type}")) + })?; + headers.insert(http::header::CONTENT_TYPE, content_type); + Bytes::copy_from_slice(bytes) + } + HttpBody::Empty => Bytes::new(), + }; + Ok(FetchRequest { + method: match request.method { + HttpMethod::Get => http::Method::GET, + HttpMethod::Post => http::Method::POST, + }, + url, + headers, + body, + redirect, + signal: None, + timeout: None, }) } @@ -677,16 +577,15 @@ pub async fn sleep_or_abort( } fn observe_response( - response: reqwest::Response, + response: FetchResponse, request: &PreparedRequest, started: Instant, latency_ms: u64, attempt: u32, exchange_index: u32, -) -> reqwest::Response { - let status = response.status(); - let version = response.version(); - let headers = response.headers().clone(); +) -> FetchResponse { + let status = response.status; + let headers = response.headers.clone(); let recording = request.recording_context.as_ref().map(|context| { let redacted_headers = redacted_response_headers(&headers); let response = ResponseRecord { @@ -728,23 +627,22 @@ fn observe_response( } }); let observed = ObservedBody { - inner: response.bytes_stream().boxed(), + inner: response.body, started, chunks: 0, done: false, recording, }; - let mut rebuilt = http::Response::builder() - .status(status) - .version(version) - .body(reqwest::Body::wrap_stream(observed)) - .expect("status and version came from a valid reqwest response"); - *rebuilt.headers_mut() = headers; - rebuilt.into() + FetchResponse { + status, + headers, + url: response.url, + body: observed.boxed(), + } } struct ObservedBody { - inner: BoxStream<'static, Result>, + inner: BoxStream<'static, Result>, started: Instant, chunks: usize, done: bool, @@ -752,7 +650,7 @@ struct ObservedBody { } impl Stream for ObservedBody { - type Item = Result; + type Item = Result; fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { if self.done { @@ -953,7 +851,7 @@ fn record_failed_exchange( ); } -fn redacted_response_headers(headers: &reqwest::header::HeaderMap) -> Vec<(String, String)> { +fn redacted_response_headers(headers: &http::HeaderMap) -> Vec<(String, String)> { crate::extract_response_headers::extract_response_header_pairs(headers) } diff --git a/aimux-provider-utils/src/lib.rs b/aimux-provider-utils/src/lib.rs index fa33eb5df..0d45ea53e 100644 --- a/aimux-provider-utils/src/lib.rs +++ b/aimux-provider-utils/src/lib.rs @@ -2,14 +2,17 @@ //! //! Shared utilities for provider implementations. //! -//! Provides one-exchange HTTP helpers, response handlers, API key loading, -//! header management, URL utilities and the streamed tool-call tracker for -//! the OpenAI chat-completions wire format — the Rust equivalents of -//! `@ai-sdk/provider-utils`. Operation retry and timeout live in `aimux-core`. +//! Provides the pluggable [`Fetch`] transport, one-exchange HTTP helpers, +//! response handlers, API key and setting loading, [`Resolvable`] values and +//! layered header management, URL utilities and the streamed tool-call +//! tracker for the OpenAI chat-completions wire format — the Rust equivalents +//! of `@ai-sdk/provider-utils`. Operation retry and timeout live in +//! `aimux-core`. pub mod api_key; mod download_guard; pub mod extract_response_headers; +pub mod fetch; pub mod get_from_api; pub mod handle_fetch_error; pub mod headers; @@ -18,8 +21,9 @@ pub mod logging; pub mod multipart; pub mod post_to_api; pub mod read_response_with_size_limit; +pub mod resolvable; pub mod response_handler; -pub mod retry; +pub mod sigv4_fetch; pub mod streaming_tool_call_argument_state; pub mod streaming_tool_call_tracker; pub mod url; @@ -28,16 +32,21 @@ pub mod url; #[cfg(feature = "ws")] pub mod ws; -pub use api_key::load_api_key; +pub use api_key::{load_api_key, load_optional_setting, load_setting}; pub use download_guard::same_origin; +pub use fetch::{ + Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, PinnedFetch, RedirectPolicy, + ReqwestFetch, default_fetch, +}; pub use get_from_api::get_from_api; -pub use headers::with_user_agent_suffix; -pub use http::{ - ExchangeContext, HttpBody, HttpRequest, ProxyConfig, init_proxy, shared_client, sleep_or_abort, +pub use headers::{ + HeaderMapOpt, HeadersFn, combine_headers, normalize_headers, with_user_agent_suffix, }; +pub use http::{ExchangeContext, HttpBody, HttpRequest, ProxyConfig, init_proxy, sleep_or_abort}; pub use logging::{body_logging_enabled, init_logging, redact_body, redact_error_context}; pub use multipart::{MultipartForm, media_type_to_extension}; pub use post_to_api::{post_form_data_to_api, post_json_to_api, post_to_api}; +pub use resolvable::Resolvable; pub use response_handler::{ ProviderErrorParts, ResponseHandler, ResponseHandlerInput, ResponseHandlerOutput, create_binary_response_handler, create_event_source_response_handler, @@ -45,7 +54,7 @@ pub use response_handler::{ create_standard_json_error_response_handler, create_status_code_error_response_handler, stream_error_api_call, }; -pub use retry::RetryConfig; +pub use sigv4_fetch::{AwsCredentials, SigV4Fetch, SignedRequest, sign_request}; pub use streaming_tool_call_argument_state::{ StreamingToolCallArgumentState, starts_with_structured_value, }; diff --git a/aimux-provider-utils/src/read_response_with_size_limit.rs b/aimux-provider-utils/src/read_response_with_size_limit.rs index bd5d1934a..d4b547047 100644 --- a/aimux-provider-utils/src/read_response_with_size_limit.rs +++ b/aimux-provider-utils/src/read_response_with_size_limit.rs @@ -5,6 +5,8 @@ use futures::StreamExt; use aimux_core::{AiMuxError, ApiCallError}; +use crate::fetch::FetchResponse; + /// Default maximum buffered response size for binary downloads (2 GiB, /// matching AI SDK). pub const DEFAULT_MAX_DOWNLOAD_SIZE: usize = 2 * 1024 * 1024 * 1024; @@ -24,7 +26,7 @@ pub const DEFAULT_MAX_JSON_RESPONSE_SIZE: usize = 64 * 1024 * 1024; /// Returns an API-call error when the body exceeds `max_bytes` or cannot be /// read, and an abort error when the caller cancels. pub async fn read_response_with_size_limit( - response: reqwest::Response, + response: FetchResponse, url: &str, request_body_values: &serde_json::Value, max_bytes: usize, @@ -107,7 +109,7 @@ pub async fn read_response_with_size_limit( /// Returns an abort error when the caller cancels; never fails on read or /// size problems. pub(crate) async fn read_error_body_truncated( - response: reqwest::Response, + response: FetchResponse, max_bytes: usize, abort_signal: Option<&aimux_core::AbortSignal>, ) -> Result<(Vec, bool), AiMuxError> { diff --git a/aimux-provider-utils/src/resolvable.rs b/aimux-provider-utils/src/resolvable.rs new file mode 100644 index 000000000..8d2cee627 --- /dev/null +++ b/aimux-provider-utils/src/resolvable.rs @@ -0,0 +1,157 @@ +//! A value that may be supplied directly, computed on demand, or awaited. +//! +//! The AI SDK's provider settings accept `string | (() => string | Promise)` +//! for API keys and headers (`Resolvable` in `@ai-sdk/provider-utils`). +//! This is the Rust shape of that union. + +use std::fmt; +use std::sync::Arc; + +use futures::FutureExt; +use futures::future::{BoxFuture, Shared}; + +use aimux_core::AiMuxError; + +/// A value of `T`, or a way to obtain one when a request is made. +/// +/// `Future` and `AsyncFn` differ in *when* they compute: a `Future` is +/// evaluated at most once and every [`resolve`](Self::resolve) shares the +/// cached outcome (a promise value), while an `AsyncFn` / `Fn` runs again on +/// every resolve (a token refresher, a credential chain). +pub enum Resolvable { + /// A plain value. + Value(T), + /// A synchronous, fallible producer, called on every resolve. + Fn(Arc Result + Send + Sync>), + /// An asynchronous producer, called on every resolve. + AsyncFn(Arc BoxFuture<'static, Result> + Send + Sync>), + /// A one-shot future whose outcome (success or failure) is cached. + Future(Shared>>), +} + +impl Resolvable { + /// Obtain the value. + /// + /// # Errors + /// + /// Returns whatever error the producer yields. + pub async fn resolve(&self) -> Result { + match self { + Self::Value(value) => Ok(value.clone()), + Self::Fn(produce) => produce(), + Self::AsyncFn(produce) => produce().await, + Self::Future(future) => future.clone().await, + } + } + + /// A synchronous producer evaluated on every resolve. + #[must_use] + pub fn from_fn(produce: impl Fn() -> Result + Send + Sync + 'static) -> Self { + Self::Fn(Arc::new(produce)) + } + + /// An asynchronous producer evaluated on every resolve. + #[must_use] + pub fn from_async_fn(produce: impl Fn() -> Fut + Send + Sync + 'static) -> Self + where + Fut: std::future::Future> + Send + 'static, + { + Self::AsyncFn(Arc::new(move || produce().boxed())) + } + + /// A future evaluated at most once, its outcome shared by every resolve. + #[must_use] + pub fn from_future(future: Fut) -> Self + where + Fut: std::future::Future> + Send + 'static, + { + Self::Future(future.boxed().shared()) + } +} + +impl From for Resolvable { + fn from(value: T) -> Self { + Self::Value(value) + } +} + +impl Clone for Resolvable { + fn clone(&self) -> Self { + match self { + Self::Value(value) => Self::Value(value.clone()), + Self::Fn(produce) => Self::Fn(produce.clone()), + Self::AsyncFn(produce) => Self::AsyncFn(produce.clone()), + Self::Future(future) => Self::Future(future.clone()), + } + } +} + +impl fmt::Debug for Resolvable { + /// Never prints the value: a `Resolvable` commonly carries credentials. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Value(_) => "Resolvable::Value(..)", + Self::Fn(_) => "Resolvable::Fn(..)", + Self::AsyncFn(_) => "Resolvable::AsyncFn(..)", + Self::Future(_) => "Resolvable::Future(..)", + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[tokio::test] + async fn value_and_fn_resolve() { + assert_eq!( + Resolvable::from("key".to_string()).resolve().await.unwrap(), + "key" + ); + let value = Resolvable::from_fn(|| Ok::<_, AiMuxError>(7_u32)); + assert_eq!(value.resolve().await.unwrap(), 7); + } + + #[tokio::test] + async fn async_fn_recomputes_on_every_resolve() { + let calls = Arc::new(AtomicUsize::new(0)); + let counter = calls.clone(); + let value = Resolvable::from_async_fn(move || { + let counter = counter.clone(); + async move { Ok::<_, AiMuxError>(counter.fetch_add(1, Ordering::SeqCst)) } + }); + assert_eq!(value.resolve().await.unwrap(), 0); + assert_eq!(value.resolve().await.unwrap(), 1); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn future_is_evaluated_once_and_shared_across_clones() { + let calls = Arc::new(AtomicUsize::new(0)); + let counter = calls.clone(); + let value = Resolvable::from_future(async move { + Ok::<_, AiMuxError>(counter.fetch_add(1, Ordering::SeqCst)) + }); + let clone = value.clone(); + assert_eq!(value.resolve().await.unwrap(), 0); + assert_eq!(value.resolve().await.unwrap(), 0); + assert_eq!(clone.resolve().await.unwrap(), 0); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn errors_propagate_and_a_future_caches_its_failure() { + let value: Resolvable = + Resolvable::from_future(async { Err(AiMuxError::Other("boom".into())) }); + assert!(matches!(value.resolve().await, Err(AiMuxError::Other(_)))); + assert!(matches!(value.resolve().await, Err(AiMuxError::Other(_)))); + } + + #[test] + fn debug_never_prints_the_value() { + let value = Resolvable::from("sk-secret".to_string()); + assert!(!format!("{value:?}").contains("sk-secret")); + } +} diff --git a/aimux-provider-utils/src/response_handler.rs b/aimux-provider-utils/src/response_handler.rs index 2042669df..1d129c44f 100644 --- a/aimux-provider-utils/src/response_handler.rs +++ b/aimux-provider-utils/src/response_handler.rs @@ -10,6 +10,7 @@ use serde::de::DeserializeOwned; use aimux_core::{AiMuxError, ApiCallError}; use crate::extract_response_headers::extract_response_headers; +use crate::fetch::FetchResponse; use crate::read_response_with_size_limit::{ DEFAULT_MAX_DOWNLOAD_SIZE, DEFAULT_MAX_JSON_RESPONSE_SIZE, read_response_with_size_limit, }; @@ -18,7 +19,7 @@ use crate::read_response_with_size_limit::{ pub struct ResponseHandlerInput { pub url: String, pub request_body_values: serde_json::Value, - pub response: reqwest::Response, + pub response: FetchResponse, pub abort_signal: Option, /// Per-request override of the successful-JSON-body size cap, forwarded /// from `HttpRequest::max_json_response_bytes`. Only diff --git a/aimux-provider-utils/src/retry.rs b/aimux-provider-utils/src/retry.rs deleted file mode 100644 index aba3b9821..000000000 --- a/aimux-provider-utils/src/retry.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Compatibility exports for retry configuration. - -pub use aimux_core::retry::RetryConfig; diff --git a/aimux-provider-utils/src/sigv4_fetch.rs b/aimux-provider-utils/src/sigv4_fetch.rs new file mode 100644 index 000000000..de56a0de5 --- /dev/null +++ b/aimux-provider-utils/src/sigv4_fetch.rs @@ -0,0 +1,527 @@ +//! AWS Signature Version 4 request signing, as a [`Fetch`] decorator. +//! +//! [`SigV4Fetch`] wraps another transport and signs each request's *final* +//! body bytes just before they are sent, so Bedrock and Claude Platform on AWS +//! can authenticate through ordinary provider settings (`fetch`) instead of +//! each model re-implementing header construction. +//! +//! The signer is a self-contained implementation using only `sha2` + `hmac` +//! (no AWS SDK dependency). It supports static credentials +//! (`access_key_id` + `secret_access_key` + optional `session_token`), any AWS +//! service name and region, and any method/body. +//! +//! Reference: + +use std::fmt; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; + +use crate::fetch::{Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse}; +use crate::resolvable::Resolvable; + +/// A set of AWS credentials for SigV4 signing. +#[derive(Debug, Clone)] +pub struct AwsCredentials { + pub access_key_id: String, + pub secret_access_key: String, + /// Optional temporary session token (STS). + pub session_token: Option, + pub region: String, +} + +/// The result of signing a request. +#[derive(Debug, Clone)] +pub struct SignedRequest { + /// All headers to send with the request, including the `Authorization` + /// header and any `X-Amz-*` headers. + pub headers: Vec<(String, String)>, +} + +type HmacSha256 = Hmac; + +/// Sign an HTTP request using AWS SigV4, stamped with the current time. +/// +/// # Arguments +/// - `credentials` - The AWS credentials to sign with. +/// - `service` - The AWS service name (e.g. `"bedrock"`, `"aws-external-anthropic"`). +/// - `method` - HTTP method (e.g. `"POST"`). +/// - `url` - The full request URL. +/// - `body` - The request body as a string (e.g. JSON). +/// - `extra_headers` - Additional headers to include in the request (these are +/// also included in the canonical headers for signing). +#[must_use] +pub fn sign_request( + credentials: &AwsCredentials, + service: &str, + method: &str, + url: &str, + body: &str, + extra_headers: &[(String, String)], +) -> SignedRequest { + sign_request_at( + credentials, + service, + method, + url, + body.as_bytes(), + extra_headers, + Utc::now(), + ) +} + +/// [`sign_request`] with an explicit signing time and a byte body. +#[must_use] +pub fn sign_request_at( + credentials: &AwsCredentials, + service: &str, + method: &str, + url: &str, + body: &[u8], + extra_headers: &[(String, String)], + now: DateTime, +) -> SignedRequest { + let parsed = + url::Url::parse(url).unwrap_or_else(|_| url::Url::parse("http://localhost").unwrap()); + // The signed host must match the wire host: keep the port whenever the + // URL carries a non-default one (Url::port() is Some only for non-default + // ports). Signing a bare host diverges from the Host header reqwest would + // otherwise so gateways and proxies on non-standard ports reject the + // signature (observed as 502 when an env proxy forwarded a signed + // loopback request whose host lacked the port). + let host = match parsed.port() { + Some(port) => format!("{}:{}", parsed.host_str().unwrap_or(""), port), + None => parsed.host_str().unwrap_or("").to_string(), + }; + let path = parsed.path(); + let query = parsed.query().unwrap_or(""); + + // Timestamp:yyyyMMddTHHmmssZ and date:yyyyMMdd + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + + // Body hash + let body_hash = hex::encode(Sha256::digest(body)); + + // Build canonical headers (sorted, lowercased, trimmed). + // Always include host, x-amz-date, and x-amz-content-sha256. + let mut canonical_headers: Vec<(String, String)> = vec![ + ("host".to_string(), host.to_string()), + ("x-amz-content-sha256".to_string(), body_hash.clone()), + ("x-amz-date".to_string(), amz_date.clone()), + ]; + + if let Some(ref token) = credentials.session_token { + canonical_headers.push(("x-amz-security-token".to_string(), token.clone())); + } + + for (k, v) in extra_headers { + let lower = k.to_lowercase(); + // Don't override the required headers. + if lower != "host" + && lower != "x-amz-date" + && lower != "x-amz-content-sha256" + && lower != "x-amz-security-token" + { + canonical_headers.push((lower, v.trim().to_string())); + } + } + + canonical_headers.sort_by(|a, b| a.0.cmp(&b.0)); + + let canonical_headers_str: String = canonical_headers + .iter() + .map(|(k, v)| format!("{k}:{v}\n")) + .collect(); + let signed_headers: String = canonical_headers + .iter() + .map(|(k, _)| k.as_str()) + .collect::>() + .join(";"); + + // Canonical request + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + method.to_uppercase(), + path, + query, + canonical_headers_str, + signed_headers, + body_hash + ); + + let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes())); + + // Credential scope + let credential_scope = format!( + "{}/{}/{}/aws4_request", + date_stamp, credentials.region, service + ); + + // String to sign + let string_to_sign = + format!("AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{canonical_request_hash}"); + + // Signing key: derived through chained HMAC + let k_date = hmac_sha256( + date_stamp.as_bytes(), + credentials.secret_access_key.as_bytes(), + ); + let k_region = hmac_sha256(credentials.region.as_bytes(), &k_date); + let k_service = hmac_sha256(service.as_bytes(), &k_region); + let k_signing = hmac_sha256(b"aws4_request", &k_service); + + let signature = hex::encode(hmac_sha256(string_to_sign.as_bytes(), &k_signing)); + + let authorization = format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + credentials.access_key_id, credential_scope, signed_headers, signature + ); + + // Build the final header list. + let mut headers: Vec<(String, String)> = canonical_headers + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + headers.push(("Authorization".to_string(), authorization)); + + SignedRequest { headers } +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +type Clock = Arc DateTime + Send + Sync>; + +/// A [`Fetch`] decorator that signs every request with SigV4 and forwards it +/// to the wrapped transport. +/// +/// Credentials are resolved on each request, so a `Resolvable::AsyncFn` can +/// hand out rotating STS credentials. The signature covers the request's +/// final body bytes and every header present on the request (except +/// `Authorization`, which it replaces); `host`, `x-amz-date`, +/// `x-amz-content-sha256` and, for temporary credentials, +/// `x-amz-security-token` are set from the signed values. +pub struct SigV4Fetch { + inner: FetchFunction, + credentials: Resolvable, + service: String, + clock: Clock, +} + +impl SigV4Fetch { + /// Sign requests for `service` (e.g. `"bedrock"`) with `credentials` + /// (whose `region` scopes the signature) and forward them to `inner`. + #[must_use] + pub fn new( + inner: FetchFunction, + credentials: Resolvable, + service: impl Into, + ) -> Self { + Self { + inner, + credentials, + service: service.into(), + clock: Arc::new(Utc::now), + } + } + + /// Replace the signing clock (deterministic signatures in tests). + #[must_use] + pub fn with_clock(mut self, clock: impl Fn() -> DateTime + Send + Sync + 'static) -> Self { + self.clock = Arc::new(clock); + self + } +} + +impl fmt::Debug for SigV4Fetch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SigV4Fetch") + .field("service", &self.service) + .finish_non_exhaustive() + } +} + +#[async_trait] +impl Fetch for SigV4Fetch { + async fn fetch(&self, mut request: FetchRequest) -> Result { + let credentials = self.credentials.resolve().await.map_err(|error| { + FetchError::Other(format!("failed to resolve AWS credentials: {error}")) + })?; + let mut extra_headers = Vec::with_capacity(request.headers.len()); + for (name, value) in &request.headers { + if name == http::header::AUTHORIZATION { + continue; + } + let value = value.to_str().map_err(|_| { + FetchError::Other(format!("cannot sign non-ASCII header value for {name}")) + })?; + extra_headers.push((name.as_str().to_string(), value.to_string())); + } + let signed = sign_request_at( + &credentials, + &self.service, + request.method.as_str(), + request.url.as_str(), + &request.body, + &extra_headers, + (self.clock)(), + ); + for (name, value) in signed.headers { + let name = http::HeaderName::try_from(name.as_str()).map_err(|error| { + FetchError::Other(format!("invalid signed header name {name}: {error}")) + })?; + let value = http::HeaderValue::try_from(value.as_str()).map_err(|error| { + FetchError::Other(format!("invalid signed header value for {name}: {error}")) + })?; + request.headers.insert(name, value); + } + self.inner.fetch(request).await + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use bytes::Bytes; + use chrono::TimeZone; + use http::{HeaderMap, Method, StatusCode}; + + use super::*; + + fn creds() -> AwsCredentials { + AwsCredentials { + access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), + secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), + session_token: None, + region: "us-east-1".to_string(), + } + } + + fn fixed_time() -> DateTime { + Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap() + } + + fn header(signed: &SignedRequest, name: &str) -> String { + signed + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| panic!("signed request carries a {name} header")) + } + + /// Records the (already signed) request the decorator forwards. + #[derive(Default)] + struct Capture(Mutex>); + + #[async_trait] + impl Fetch for Capture { + async fn fetch(&self, request: FetchRequest) -> Result { + let url = request.url.clone(); + *self.0.lock().unwrap() = Some(request); + Ok(FetchResponse::from_bytes( + StatusCode::OK, + HeaderMap::new(), + url, + Bytes::new(), + )) + } + } + + fn decorated(credentials: AwsCredentials) -> (SigV4Fetch, Arc) { + let capture = Arc::new(Capture::default()); + let fetch = SigV4Fetch::new(capture.clone(), Resolvable::from(credentials), "bedrock") + .with_clock(fixed_time); + (fetch, capture) + } + + const URL: &str = "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse"; + const BODY: &str = r#"{"messages":[]}"#; + + #[test] + fn host_keeps_non_default_port() { + let signed = sign_request( + &creds(), + "bedrock", + "POST", + "http://127.0.0.1:54321/model/x/invoke", + "{}", + &[], + ); + assert_eq!(header(&signed, "host"), "127.0.0.1:54321"); + } + + #[test] + fn host_omits_default_port() { + let signed = sign_request( + &creds(), + "bedrock", + "POST", + "https://bedrock-runtime.us-east-1.amazonaws.com/model/x/invoke", + "{}", + &[], + ); + assert_eq!( + header(&signed, "host"), + "bedrock-runtime.us-east-1.amazonaws.com" + ); + } + + /// Pins the signer byte-for-byte to the pre-move `bedrock::sigv4` + /// implementation: the expected values come from an independent + /// re-implementation of that exact algorithm run at the same fixed time. + #[test] + fn signer_output_is_unchanged_by_the_move() { + let signed = sign_request_at( + &creds(), + "bedrock", + "POST", + URL, + BODY.as_bytes(), + &[("X-Custom".to_string(), " value ".to_string())], + fixed_time(), + ); + assert_eq!( + header(&signed, "authorization"), + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20240102/us-east-1/bedrock/aws4_request, \ + SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-custom, \ + Signature=50198f4d3c821ae4aa0518965cd3ecaeb81e542a155dc57766e1e28a6ae696b8" + ); + + let session = AwsCredentials { + session_token: Some("SESSION".to_string()), + ..creds() + }; + let signed = sign_request_at( + &session, + "bedrock", + "POST", + "http://127.0.0.1:54321/model/x/invoke?a=1", + b"{}", + &[], + fixed_time(), + ); + assert_eq!( + header(&signed, "authorization"), + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20240102/us-east-1/bedrock/aws4_request, \ + SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, \ + Signature=8421497f0591d30210a67833cdd30e0b8693f941ed28ecf4aff8751531cbf783" + ); + assert_eq!(header(&signed, "x-amz-security-token"), "SESSION"); + } + + #[tokio::test] + async fn decorator_authorization_is_byte_identical_to_sign_request() { + let (fetch, capture) = decorated(creds()); + let mut request = FetchRequest::new(Method::POST, URL.parse().unwrap()); + request + .headers + .insert("x-custom", " value ".parse().unwrap()); + request.body = Bytes::from(BODY); + + fetch.fetch(request).await.unwrap(); + + let sent = capture + .0 + .lock() + .unwrap() + .take() + .expect("inner fetch was called"); + let expected = sign_request_at( + &creds(), + "bedrock", + "POST", + URL, + BODY.as_bytes(), + &[("x-custom".to_string(), " value ".to_string())], + fixed_time(), + ); + assert_eq!( + sent.headers["authorization"].to_str().unwrap(), + header(&expected, "authorization") + ); + for name in ["host", "x-amz-date", "x-amz-content-sha256"] { + assert_eq!( + sent.headers[name].to_str().unwrap(), + header(&expected, name), + "{name}" + ); + } + assert_eq!(sent.body, Bytes::from(BODY)); + assert!(!sent.headers.contains_key("x-amz-security-token")); + } + + #[tokio::test] + async fn decorator_replaces_a_caller_authorization_and_signs_the_session_token() { + let session = AwsCredentials { + session_token: Some("SESSION".to_string()), + ..creds() + }; + let (fetch, capture) = decorated(session); + let mut request = FetchRequest::new(Method::POST, URL.parse().unwrap()); + request + .headers + .insert(http::header::AUTHORIZATION, "Bearer stale".parse().unwrap()); + request.body = Bytes::from(BODY); + + fetch.fetch(request).await.unwrap(); + + let sent = capture.0.lock().unwrap().take().unwrap(); + let authorization = sent.headers["authorization"].to_str().unwrap(); + assert!(authorization.starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")); + assert!(authorization.contains("x-amz-security-token")); + assert_eq!(sent.headers.get_all("authorization").iter().count(), 1); + assert_eq!(sent.headers["x-amz-security-token"], "SESSION"); + } + + #[tokio::test] + async fn credentials_are_resolved_per_request() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = calls.clone(); + let credentials = Resolvable::from_async_fn(move || { + let counter = counter.clone(); + async move { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(creds()) + } + }); + let capture = Arc::new(Capture::default()); + let fetch = SigV4Fetch::new(capture, credentials, "bedrock"); + for _ in 0..2 { + fetch + .fetch(FetchRequest::new(Method::GET, URL.parse().unwrap())) + .await + .unwrap(); + } + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn credential_failures_surface_without_calling_the_inner_fetch() { + let capture = Arc::new(Capture::default()); + let fetch = SigV4Fetch::new( + capture.clone(), + Resolvable::from_fn(|| { + Err(aimux_core::AiMuxError::LoadSetting { + env_var: "AWS_ACCESS_KEY_ID".into(), + name: "accessKeyId".into(), + }) + }), + "bedrock", + ); + let error = fetch + .fetch(FetchRequest::new(Method::GET, URL.parse().unwrap())) + .await + .unwrap_err(); + assert!(matches!(&error, FetchError::Other(m) if m.contains("AWS_ACCESS_KEY_ID"))); + assert!(capture.0.lock().unwrap().is_none()); + } +} diff --git a/aimux-provider-utils/src/ws.rs b/aimux-provider-utils/src/ws.rs index 2ad1c6322..0a6a5a1b2 100644 --- a/aimux-provider-utils/src/ws.rs +++ b/aimux-provider-utils/src/ws.rs @@ -22,8 +22,10 @@ //! rejected loudly rather than silently bypassed. use std::future::pending; +use std::sync::Arc; -use futures::{SinkExt, StreamExt}; +use async_trait::async_trait; +use futures::{Sink, SinkExt, Stream, StreamExt}; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; @@ -34,6 +36,7 @@ use aimux_core::error::{AiMuxError, ApiCallError}; use aimux_core::options::TimeoutConfiguration; /// A request to open a WebSocket connection. +#[derive(Debug)] pub struct WebSocketRequest { /// `wss://` or `ws://` URL. pub url: String, @@ -48,6 +51,47 @@ pub struct WebSocketRequest { /// session establishment, `chunk_ms` bounds the gap between events, /// `total_ms` bounds the whole connection lifetime. pub timeout: Option, + /// Transport that performs the handshake. `None` uses the built-in + /// tungstenite connector (with the process-wide proxy tunnel). Injection + /// point for tests and for providers that bring their own socket. + pub connector: Option>, +} + +/// Opens WebSocket connections. The built-in implementation is +/// tungstenite plus the RFC-0034 proxy tunnel; a custom one returns a +/// [`WsConnection`] built with [`WsConnection::from_transport`]. +#[async_trait] +pub trait WsConnector: Send + Sync + 'static { + /// Establish the connection described by `request`. + /// + /// # Errors + /// + /// Returns the connect failure, abort or timeout. + async fn connect(&self, request: &WebSocketRequest) -> Result; +} + +impl std::fmt::Debug for dyn WsConnector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("dyn WsConnector") + } +} + +/// The message transport behind a [`WsConnection`]: any tungstenite-message +/// stream/sink pair. +pub trait WsTransport: + Stream> + + Sink + + Send + + Unpin +{ +} + +impl WsTransport for T where + T: Stream> + + Sink + + Send + + Unpin +{ } /// An inbound WebSocket message. @@ -59,7 +103,7 @@ pub enum WsMessage { /// A connected WebSocket with abort/timeout enforcement built in. pub struct WsConnection { - stream: WebSocketStream>, + stream: Box, url: String, abort: Option, /// Deadline for the first event (connect + session ack). Cleared after @@ -398,8 +442,9 @@ async fn connect_through_proxy( Ok((websocket, response)) } -/// Open a WebSocket connection. The connect phase races abort and the -/// `first_chunk_ms` timeout (which doubles as the connect timeout). +/// Open a WebSocket connection through the request's [`WsConnector`], or the +/// built-in one when none is set. The built-in connect phase races abort and +/// the `first_chunk_ms` timeout (which doubles as the connect timeout). /// /// # Errors /// @@ -407,6 +452,24 @@ async fn connect_through_proxy( /// cancellation wins the connect race, and `Timeout` when `first_chunk_ms` /// expires while connecting. pub async fn ws_connect(req: &WebSocketRequest) -> Result { + match &req.connector { + Some(connector) => connector.connect(req).await, + None => TungsteniteConnector.connect(req).await, + } +} + +/// The built-in connector: tungstenite, with the process-wide proxy tunnel. +#[derive(Debug, Clone, Copy, Default)] +pub struct TungsteniteConnector; + +#[async_trait] +impl WsConnector for TungsteniteConnector { + async fn connect(&self, request: &WebSocketRequest) -> Result { + connect_tungstenite(request).await + } +} + +async fn connect_tungstenite(req: &WebSocketRequest) -> Result { let mut http_req = req .url .as_str() @@ -517,27 +580,52 @@ pub async fn ws_connect(req: &WebSocketRequest) -> Result, req: &WebSocketRequest) -> Self { + let first_chunk_deadline = req + .timeout + .as_ref() + .and_then(|t| t.first_chunk_ms) + .map(|ms| tokio::time::Instant::now() + tokio::time::Duration::from_millis(ms)); + Self::with_deadline(transport, req, first_chunk_deadline) + } + + fn with_deadline( + stream: Box, + req: &WebSocketRequest, + first_chunk_deadline: Option, + ) -> Self { + Self { + stream, + url: req.url.clone(), + abort: req.abort_signal.clone(), + first_chunk_deadline, + chunk_timeout: req + .timeout + .as_ref() + .and_then(|t| t.chunk_ms) + .map(tokio::time::Duration::from_millis), + total_deadline: req + .timeout + .as_ref() + .and_then(|t| t.total_ms) + .map(|ms| tokio::time::Instant::now() + tokio::time::Duration::from_millis(ms)), + } + } + /// Send a text message. Aborted / timed out sends surface as errors; the /// pending-while-buffer-full behavior is the socket-level backpressure. /// @@ -640,12 +728,14 @@ impl WsConnection { /// a close handshake against a dead peer must not hang the caller (the /// send pends while the socket buffer can't drain). pub async fn close(&mut self) { - let close_fut = self.stream.close(Some( + // What `WebSocketStream::close(Some(frame))` does, expressed through + // the transport trait so injected connectors close the same way. + let close_fut = self.stream.send(Message::Close(Some( tokio_tungstenite::tungstenite::protocol::frame::CloseFrame { code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Normal, reason: std::borrow::Cow::Borrowed("finished"), }, - )); + ))); tokio::select! { res = close_fut => { let _ = res; diff --git a/aimux-provider-utils/tests/download_guard_test.rs b/aimux-provider-utils/tests/download_guard_test.rs index 52995b512..0dd42b9f0 100644 --- a/aimux-provider-utils/tests/download_guard_test.rs +++ b/aimux-provider-utils/tests/download_guard_test.rs @@ -34,6 +34,7 @@ fn request( validate_url: true, trusted_origin: trusted_origin.map(str::to_owned), credentialed_origin: credentialed_origin.map(str::to_owned), + fetch: None, } } diff --git a/aimux-provider-utils/tests/fetch_injection_test.rs b/aimux-provider-utils/tests/fetch_injection_test.rs new file mode 100644 index 000000000..21cd36a89 --- /dev/null +++ b/aimux-provider-utils/tests/fetch_injection_test.rs @@ -0,0 +1,270 @@ +//! A `Fetch` injected on the request replaces the network leaf for every +//! helper (`post_json_to_api`, `get_from_api`), while the layers above it — +//! header insertion, error mapping, abort, the download guard — keep working. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use bytes::Bytes; +use http::{HeaderMap, StatusCode}; +use serde::Deserialize; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use aimux_core::{AbortSignal, AiMuxError}; +use aimux_provider_utils::{ + Fetch, FetchError, FetchFunction, FetchRequest, FetchResponse, HttpRequest, ProviderErrorParts, + create_json_error_response_handler, create_json_response_handler, get_from_api, + post_json_to_api, +}; + +#[derive(Debug, Deserialize, PartialEq)] +struct Reply { + answer: u32, +} + +/// Records every request and replies with a canned result. +struct MockFetch { + requests: Mutex>, + reply: Box Result<(StatusCode, String), FetchError> + Send + Sync>, +} + +impl MockFetch { + fn json(status: u16, body: serde_json::Value) -> Arc { + let body = body.to_string(); + Arc::new(Self { + requests: Mutex::new(Vec::new()), + reply: Box::new(move || Ok((StatusCode::from_u16(status).unwrap(), body.clone()))), + }) + } + + fn failing(error: FetchError) -> Arc { + Arc::new(Self { + requests: Mutex::new(Vec::new()), + reply: Box::new(move || Err(error.clone())), + }) + } + + fn calls(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +#[async_trait] +impl Fetch for MockFetch { + async fn fetch(&self, request: FetchRequest) -> Result { + let url = request.url.clone(); + self.requests.lock().unwrap().push(request); + let (status, body) = (self.reply)()?; + Ok(FetchResponse::from_bytes( + status, + HeaderMap::new(), + url, + Bytes::from(body), + )) + } +} + +fn failed() -> aimux_provider_utils::ResponseHandler { + create_json_error_response_handler(|value| ProviderErrorParts { + message: value["error"] + .as_str() + .unwrap_or("request failed") + .to_string(), + provider_code: None, + }) +} + +fn request(url: &str, fetch: &Arc) -> HttpRequest { + let fetch: FetchFunction = fetch.clone(); + HttpRequest { + url: url.to_string(), + headers: vec![("x-custom".into(), "visible".into())], + fetch: Some(fetch), + ..Default::default() + } +} + +#[tokio::test] +async fn post_json_to_api_goes_through_the_injected_fetch() { + let mock = MockFetch::json(200, json!({"answer": 42})); + + let output = post_json_to_api( + request("https://api.example.test/v1/chat?alt=json", &mock), + json!({"hello": "world"}), + create_json_response_handler::(), + failed(), + ) + .await + .unwrap(); + + assert_eq!(output.value, Reply { answer: 42 }); + let calls = mock.calls(); + assert_eq!(calls.len(), 1); + let sent = &calls[0]; + assert_eq!(sent.method, http::Method::POST); + assert_eq!( + sent.url.as_str(), + "https://api.example.test/v1/chat?alt=json" + ); + assert_eq!(sent.headers["x-custom"], "visible"); + assert_eq!(sent.headers["content-type"], "application/json"); + assert_eq!( + serde_json::from_slice::(&sent.body).unwrap(), + json!({"hello": "world"}) + ); + assert_eq!(sent.redirect, aimux_provider_utils::RedirectPolicy::Follow); +} + +#[tokio::test] +async fn get_from_api_goes_through_the_injected_fetch() { + let mock = MockFetch::json(200, json!({"answer": 7})); + + let output = get_from_api( + request("https://api.example.test/v1/models", &mock), + create_json_response_handler::(), + failed(), + ) + .await + .unwrap(); + + assert_eq!(output.value, Reply { answer: 7 }); + let calls = mock.calls(); + assert_eq!(calls[0].method, http::Method::GET); + assert!(calls[0].body.is_empty()); +} + +#[tokio::test] +async fn same_named_headers_are_inserted_not_appended() { + let mock = MockFetch::json(200, json!({"answer": 1})); + let mut http_request = request("https://api.example.test/v1/chat", &mock); + http_request.headers = vec![ + ("Authorization".into(), "Bearer provider".into()), + ("x-keep".into(), "1".into()), + ("authorization".into(), "Bearer call".into()), + ]; + + post_json_to_api( + http_request, + json!({}), + create_json_response_handler::(), + failed(), + ) + .await + .unwrap(); + + let sent = &mock.calls()[0]; + let values: Vec<_> = sent.headers.get_all("authorization").iter().collect(); + assert_eq!( + values.len(), + 1, + "one Authorization header: {:?}", + sent.headers + ); + assert_eq!(values[0], "Bearer call"); + assert_eq!(sent.headers["x-keep"], "1"); +} + +#[tokio::test] +async fn http_error_statuses_reach_the_failed_response_handler() { + let mock = MockFetch::json(429, json!({"error": "slow down"})); + + let error = post_json_to_api( + request("https://api.example.test/v1/chat", &mock), + json!({}), + create_json_response_handler::(), + failed(), + ) + .await + .unwrap_err(); + + match error { + AiMuxError::ApiCall(detail) => { + assert_eq!(detail.status_code, Some(429)); + assert!(detail.is_retryable); + assert!(detail.message.contains("slow down")); + } + other => panic!("expected ApiCall, got {other:?}"), + } +} + +#[tokio::test] +async fn transport_failures_become_retryable_status_less_api_calls() { + let mock = MockFetch::failing(FetchError::Connect("connection refused".into())); + + let error = post_json_to_api( + request("https://api.example.test/v1/chat?key=secret", &mock), + json!({}), + create_json_response_handler::(), + failed(), + ) + .await + .unwrap_err(); + + match error { + AiMuxError::ApiCall(detail) => { + assert!(detail.is_retryable); + assert_eq!(detail.status_code, None); + assert!(detail.message.contains("connection refused")); + assert!( + !detail.url.contains("secret"), + "query must be stripped: {}", + detail.url + ); + } + other => panic!("expected ApiCall, got {other:?}"), + } +} + +#[tokio::test] +async fn an_aborted_signal_wins_before_the_fetch_is_called() { + let mock = MockFetch::json(200, json!({"answer": 1})); + let signal = AbortSignal::new(); + signal.abort(); + let mut http_request = request("https://api.example.test/v1/chat", &mock); + http_request.abort_signal = Some(signal); + + let error = post_json_to_api( + http_request, + json!({}), + create_json_response_handler::(), + failed(), + ) + .await + .unwrap_err(); + + assert!(matches!(error, AiMuxError::Aborted(_)), "{error:?}"); + assert!(mock.calls().is_empty()); +} + +/// D26: a validated download is never routed through a provider-injected +/// transport — it uses the guard's pinned transport instead. +#[tokio::test] +async fn validate_url_ignores_the_injected_fetch() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/asset")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"answer": 9}))) + .mount(&server) + .await; + let mock = MockFetch::json(200, json!({"answer": 0})); + let mut http_request = request(&format!("{}/asset", server.uri()), &mock); + http_request.headers = vec![]; + http_request.validate_url = true; + http_request.trusted_origin = Some(server.uri()); + + let output = get_from_api( + http_request, + create_json_response_handler::(), + failed(), + ) + .await + .unwrap(); + + assert_eq!(output.value, Reply { answer: 9 }); + assert!( + mock.calls().is_empty(), + "the injected fetch must not see downloads" + ); +} diff --git a/aimux-provider-utils/tests/load_api_key.rs b/aimux-provider-utils/tests/load_api_key.rs index 1af14f5e7..9c5962c97 100644 --- a/aimux-provider-utils/tests/load_api_key.rs +++ b/aimux-provider-utils/tests/load_api_key.rs @@ -1,4 +1,4 @@ -//! Rust tests for `load_api_key`, the Rust equivalent of +//! Rust tests for `load_api_key`, the Rust equivalent of //! `packages/provider-utils/src/load-api-key.ts`. //! //! There is no `load-api-key.test.ts` in the upstream SDK, so these tests are @@ -8,6 +8,7 @@ //! they cannot run concurrently and race on the shared env var. use aimux_core::AiMuxError; +use aimux_provider_utils::api_key::{load_optional_setting, load_setting}; use aimux_provider_utils::load_api_key; use serial_test::serial; @@ -52,25 +53,31 @@ fn reads_api_key_from_environment_variable() { #[test] #[serial] -fn returns_invalid_argument_when_neither_provided() { +fn returns_load_api_key_error_when_neither_provided() { // TS: throws LoadAPIKeyError when apiKey is null and the env var is unset. cleanup(); remove_env(); let err = load_api_key(None, ENV_VAR, "Test API key").unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_))); + assert!(matches!(err, AiMuxError::LoadApiKey { .. })); } #[test] #[serial] -fn error_message_mentions_description_and_env_var() { - // The error message guides the user to both the parameter and the env var. +fn error_carries_description_and_env_var_and_mentions_both() { + // The error guides the user to both the parameter and the env var. cleanup(); remove_env(); let err = load_api_key(None, ENV_VAR, "Test API key").unwrap_err(); - let msg = match err { - AiMuxError::InvalidArgument(m) => m, - other => panic!("expected InvalidArgument error, got {other:?}"), + let AiMuxError::LoadApiKey { + env_var, + description, + } = &err + else { + panic!("expected LoadApiKey error, got {err:?}"); }; + assert_eq!(env_var, ENV_VAR); + assert_eq!(description, "Test API key"); + let msg = err.to_string(); assert!( msg.contains("Test API key"), "message should mention description: {msg}" @@ -83,23 +90,23 @@ fn error_message_mentions_description_and_env_var() { #[test] #[serial] -fn empty_string_api_key_falls_back_to_env_var() { - // Rust behaviour (stricter than TS): an empty `api_key` string is treated - // as "not provided" and falls through to the environment variable. +fn empty_string_api_key_is_returned_verbatim_without_env_fallback() { + // TS: `typeof apiKey === 'string'` returns the value, even "". An explicit + // empty key must never be replaced by a credential from the environment. cleanup(); set_env("env-key"); let key = load_api_key(Some(""), ENV_VAR, "Test").unwrap(); - assert_eq!(key, "env-key"); + assert_eq!(key, ""); cleanup(); } #[test] #[serial] -fn empty_string_api_key_errors_when_env_var_also_unset() { +fn empty_string_api_key_is_returned_when_env_var_is_unset_too() { cleanup(); remove_env(); - let err = load_api_key(Some(""), ENV_VAR, "Test").unwrap_err(); - assert!(matches!(err, AiMuxError::InvalidArgument(_))); + let key = load_api_key(Some(""), ENV_VAR, "Test").unwrap(); + assert_eq!(key, ""); } #[test] @@ -115,12 +122,59 @@ fn explicit_api_key_takes_precedence_over_env_var() { #[test] #[serial] -fn whitespace_only_api_key_falls_back_to_env_var() { - // A whitespace-only string is non-empty, so the Rust impl returns it as-is - // (it does not trim). This documents that behaviour. +fn whitespace_only_api_key_is_returned_as_is() { + // An explicit value is never trimmed or second-guessed. cleanup(); set_env("env-key"); let key = load_api_key(Some(" "), ENV_VAR, "Test").unwrap(); assert_eq!(key, " "); cleanup(); } + +#[test] +#[serial] +fn load_setting_prefers_the_value_then_the_environment() { + cleanup(); + set_env("env-value"); + assert_eq!( + load_setting(Some("given"), ENV_VAR, "region").unwrap(), + "given" + ); + assert_eq!(load_setting(Some(""), ENV_VAR, "region").unwrap(), ""); + assert_eq!(load_setting(None, ENV_VAR, "region").unwrap(), "env-value"); + cleanup(); +} + +#[test] +#[serial] +fn load_setting_reports_the_missing_setting() { + cleanup(); + let err = load_setting(None, ENV_VAR, "region").unwrap_err(); + let AiMuxError::LoadSetting { env_var, name } = &err else { + panic!("expected LoadSetting error, got {err:?}"); + }; + assert_eq!(env_var, ENV_VAR); + assert_eq!(name, "region"); + assert!(err.to_string().contains("region")); +} + +#[test] +#[serial] +fn load_optional_setting_never_fails() { + cleanup(); + assert_eq!(load_optional_setting(None, ENV_VAR), None); + assert_eq!( + load_optional_setting(Some(""), ENV_VAR), + Some(String::new()) + ); + set_env("env-value"); + assert_eq!( + load_optional_setting(None, ENV_VAR).as_deref(), + Some("env-value") + ); + assert_eq!( + load_optional_setting(Some("given"), ENV_VAR).as_deref(), + Some("given") + ); + cleanup(); +} diff --git a/aimux-provider-utils/tests/ws_connector_test.rs b/aimux-provider-utils/tests/ws_connector_test.rs new file mode 100644 index 000000000..968267e5d --- /dev/null +++ b/aimux-provider-utils/tests/ws_connector_test.rs @@ -0,0 +1,45 @@ +//! `WebSocketRequest.connector` replaces the built-in tungstenite connect. +#![cfg(feature = "ws")] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use async_trait::async_trait; + +use aimux_core::AiMuxError; +use aimux_provider_utils::ws::{WebSocketRequest, WsConnection, WsConnector, ws_connect}; + +struct RefusingConnector(AtomicUsize); + +#[async_trait] +impl WsConnector for RefusingConnector { + async fn connect(&self, request: &WebSocketRequest) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Err(AiMuxError::Other(format!( + "custom connector: {}", + request.url + ))) + } +} + +#[tokio::test] +async fn ws_connect_delegates_to_the_injected_connector() { + let connector = Arc::new(RefusingConnector(AtomicUsize::new(0))); + let request = WebSocketRequest { + // Not connectable: only the injected connector can have produced the error. + url: "wss://unreachable.invalid/realtime".to_string(), + headers: Vec::new(), + subprotocols: Vec::new(), + abort_signal: None, + timeout: None, + connector: Some(connector.clone()), + }; + + let error = ws_connect(&request).await.err().expect("connector refuses"); + + assert!( + matches!(&error, AiMuxError::Other(m) if m == "custom connector: wss://unreachable.invalid/realtime"), + "{error:?}" + ); + assert_eq!(connector.0.load(Ordering::SeqCst), 1); +} diff --git a/aimux-provider-utils/tests/ws_proxy_test.rs b/aimux-provider-utils/tests/ws_proxy_test.rs index 90e015d49..abd85c325 100644 --- a/aimux-provider-utils/tests/ws_proxy_test.rs +++ b/aimux-provider-utils/tests/ws_proxy_test.rs @@ -205,6 +205,7 @@ fn ws_request(url: String) -> WebSocketRequest { step_ms: None, total_ms: Some(10_000), }), + connector: None, } } @@ -563,6 +564,7 @@ async fn live_wss_handshake_through_connect_proxy() { step_ms: None, total_ms: Some(20_000), }), + connector: None, }; let _ = &mut request; diff --git a/aimux-providers/src/anthropic/files.rs b/aimux-providers/src/anthropic/files.rs index 2f08101b1..09049b039 100644 --- a/aimux-providers/src/anthropic/files.rs +++ b/aimux-providers/src/anthropic/files.rs @@ -135,11 +135,7 @@ impl Files for AnthropicFiles { // `do_upload_file`), so the retry lives here — safe for the whole // exchange because an upload is not billable and a failed // create-a-file request returns no id to replay against (§9.4). - let retries = aimux_core::retry::prepare_retries( - None, - self.config.retry_config, - options.abort_signal.clone(), - ); + let retries = aimux_core::retry::prepare_retries(None, options.abort_signal.clone()); let resp = retries .retry(|| { aimux_provider_utils::post_to_api( diff --git a/aimux-providers/src/anthropic/mod.rs b/aimux-providers/src/anthropic/mod.rs index 5f2c5e39c..1e440bd12 100644 --- a/aimux-providers/src/anthropic/mod.rs +++ b/aimux-providers/src/anthropic/mod.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::provider::Provider; -use aimux_provider_utils::{RetryConfig, load_api_key}; +use aimux_provider_utils::load_api_key; use serde_json::Value; pub(crate) fn anthropic_failed_response_handler() @@ -56,8 +56,6 @@ pub struct AnthropicConfig { pub name: String, /// Extra headers merged into every request. pub headers: Option>, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, /// Provider 级请求体覆盖(RFC-0017)。在标准请求体之后 deep-merge。 pub body_overrides: Option, /// api_key 来源(RFC-0023 `ProviderRecord.api_key_source`):`None` = 显式 @@ -77,7 +75,6 @@ impl AnthropicConfig { api_version: "2023-06-01".to_string(), name: "anthropic.messages".to_string(), headers: None, - retry_config: RetryConfig::default(), body_overrides: None, api_key_source: None, } @@ -100,13 +97,6 @@ impl AnthropicConfig { self } - /// Set the retry configuration. Pass `max_retries: 0` to disable retries. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.retry_config = config; - self - } - /// Set a custom provider name. #[must_use] pub fn with_name(mut self, name: impl Into) -> Self { @@ -240,7 +230,6 @@ impl AnthropicConfigBuilder { .name .unwrap_or_else(|| "anthropic.messages".to_string()), headers: self.headers, - retry_config: RetryConfig::default(), body_overrides: self.body_overrides, api_key_source: None, }) @@ -332,7 +321,7 @@ impl Provider for AnthropicProvider { use aimux_provider_utils::HttpRequest; // Retry rationale: see `openai::model::execute_list_models`. - let resp = aimux_core::retry::prepare_retries(None, config.retry_config, None) + let resp = aimux_core::retry::prepare_retries(None, None) .retry(|| { aimux_provider_utils::get_from_api( HttpRequest { diff --git a/aimux-providers/src/anthropic/model.rs b/aimux-providers/src/anthropic/model.rs index f09e83560..6d0310698 100644 --- a/aimux-providers/src/anthropic/model.rs +++ b/aimux-providers/src/anthropic/model.rs @@ -90,10 +90,6 @@ impl LanguageModel for AnthropicModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; ProviderRecord { @@ -106,14 +102,13 @@ impl LanguageModel for AnthropicModel { .clone() .unwrap_or_else(|| "explicit".to_string()), profile: None, - // RFC-0023 C6: ProviderOptions-shaped object (headers/max_retries/ - // body_overrides are the shared ProviderOptions fields) plus the + // RFC-0023 C6: ProviderOptions-shaped object (headers/body_overrides + // are the shared ProviderOptions fields) plus the // Anthropic-specific `api_version`. Sensitive headers are redacted // at the recording boundary (recording.rs redacts provider_options). provider_options: Some(serde_json::json!({ "headers": self.config.headers, "api_version": self.config.api_version, - "max_retries": self.config.retry_config.max_retries, "body_overrides": self.config.body_overrides, })), } diff --git a/aimux-providers/src/anthropic_aws/mod.rs b/aimux-providers/src/anthropic_aws/mod.rs index 52699f9a9..9601d7e51 100644 --- a/aimux-providers/src/anthropic_aws/mod.rs +++ b/aimux-providers/src/anthropic_aws/mod.rs @@ -18,7 +18,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::provider::Provider; -use aimux_provider_utils::{RetryConfig, without_trailing_slash}; +use aimux_provider_utils::without_trailing_slash; mod model; @@ -46,8 +46,6 @@ pub struct AnthropicAwsProviderConfig { pub workspace_id: Option, /// 凭证来源(RFC-0023):`None` = explicit;`Some("env:VAR")` = 环境变量。 pub api_key_source: Option, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, } impl AnthropicAwsProviderConfig { @@ -70,7 +68,6 @@ impl AnthropicAwsProviderConfig { api_version: "2023-06-01".to_string(), workspace_id: None, api_key_source: None, - retry_config: RetryConfig::default(), } } @@ -84,7 +81,6 @@ impl AnthropicAwsProviderConfig { api_version: "2023-06-01".to_string(), workspace_id: None, api_key_source: None, - retry_config: RetryConfig::default(), } } @@ -116,13 +112,6 @@ impl AnthropicAwsProviderConfig { self } - /// Set the retry configuration. Pass `max_retries: 0` to disable retries. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.retry_config = config; - self - } - /// Add a session token for temporary STS credentials. #[must_use] pub fn with_session_token(mut self, token: impl Into) -> Self { @@ -199,7 +188,6 @@ impl AnthropicAwsProvider { api_version: self.config.api_version.clone(), workspace_id: self.config.workspace_id.clone(), api_key_source: self.config.api_key_source.clone(), - retry_config: self.config.retry_config, }, ) } @@ -249,7 +237,7 @@ impl Provider for AnthropicAwsProvider { use aimux_provider_utils::HttpRequest; // Retry rationale: see `openai::model::execute_list_models`. - let resp = aimux_core::retry::prepare_retries(None, config.retry_config, None) + let resp = aimux_core::retry::prepare_retries(None, None) .retry(|| { aimux_provider_utils::get_from_api( HttpRequest { diff --git a/aimux-providers/src/anthropic_aws/model.rs b/aimux-providers/src/anthropic_aws/model.rs index ec2d4d431..6098d2646 100644 --- a/aimux-providers/src/anthropic_aws/model.rs +++ b/aimux-providers/src/anthropic_aws/model.rs @@ -15,7 +15,6 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateResult, StreamResult}; -use aimux_provider_utils::RetryConfig; use crate::anthropic::convert::build_request_body_with_warnings; use crate::anthropic::stream::{BodyEncoding, anthropic_generate_core, anthropic_stream_core}; @@ -33,8 +32,6 @@ pub struct AnthropicAwsConfig { pub workspace_id: Option, /// 凭证来源(RFC-0023):`None` = explicit;`Some("env:VAR")` = 环境变量。 pub api_key_source: Option, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, } /// An Anthropic-AWS language model (e.g. `claude-sonnet-4-20250514`). @@ -129,10 +126,6 @@ impl LanguageModel for AnthropicAwsModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; // M2b: record identity + credential source + endpoint config. Only the diff --git a/aimux-providers/src/assemblyai.rs b/aimux-providers/src/assemblyai.rs index 3bae05556..44efc78c8 100644 --- a/aimux-providers/src/assemblyai.rs +++ b/aimux-providers/src/assemblyai.rs @@ -240,11 +240,7 @@ impl TranscriptionModel for AssemblyAITranscriptionModel { // retry would re-upload the audio to retry a later stage. An // exhausted inner retry returns `AiMuxError::Retry`, which the outer // retry passes through, so `do_generate` is never replayed (§6.2). - let retries = retry::prepare_retries( - options.max_retries, - self.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); // Step 1: Upload audio. let resp = retries diff --git a/aimux-providers/src/azure/model.rs b/aimux-providers/src/azure/model.rs index b7376abe3..10a88f5a2 100644 --- a/aimux-providers/src/azure/model.rs +++ b/aimux-providers/src/azure/model.rs @@ -29,9 +29,7 @@ use aimux_core::options::CallOptions; use aimux_core::provider::Provider; use aimux_core::result::{GenerateResult, StreamResult}; -use aimux_provider_utils::{ - RetryConfig, load_api_key, with_user_agent_suffix, without_trailing_slash, -}; +use aimux_provider_utils::{load_api_key, with_user_agent_suffix, without_trailing_slash}; use crate::azure::responses::AzureResponsesModel; use crate::openai::model::{execute_generate, execute_stream}; @@ -87,8 +85,6 @@ pub struct AzureConfig { /// api_key 来源(RFC-0023):`None` = explicit (api-key 或 token provider); /// `Some("env:VAR")` = 环境变量。不存明文。 pub api_key_source: Option, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, } impl AzureConfig { @@ -104,7 +100,6 @@ impl AzureConfig { auth: None, extra_headers: HashMap::new(), api_key_source: None, - retry_config: RetryConfig::default(), } } @@ -150,13 +145,6 @@ impl AzureConfig { self } - /// Set the retry configuration. Pass `max_retries: 0` to disable retries. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.retry_config = config; - self - } - /// Add a provider-level extra header. #[must_use] pub fn with_header(mut self, key: impl Into, value: impl Into) -> Self { @@ -326,7 +314,7 @@ impl Provider for AzureProvider { use aimux_provider_utils::HttpRequest; // Retry rationale: see `openai::model::execute_list_models`. - let resp = aimux_core::retry::prepare_retries(None, config.retry_config, None) + let resp = aimux_core::retry::prepare_retries(None, None) .retry(|| { aimux_provider_utils::get_from_api( HttpRequest { @@ -479,10 +467,6 @@ impl LanguageModel for AzureModel { &self.deployment } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; ProviderRecord { diff --git a/aimux-providers/src/azure/responses.rs b/aimux-providers/src/azure/responses.rs index 17926a4ff..e0a276788 100644 --- a/aimux-providers/src/azure/responses.rs +++ b/aimux-providers/src/azure/responses.rs @@ -236,10 +236,6 @@ impl LanguageModel for AzureResponsesModel { &self.deployment } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; ProviderRecord { diff --git a/aimux-providers/src/bedrock/embedding.rs b/aimux-providers/src/bedrock/embedding.rs index 56db97f28..c1927db38 100644 --- a/aimux-providers/src/bedrock/embedding.rs +++ b/aimux-providers/src/bedrock/embedding.rs @@ -102,10 +102,6 @@ impl EmbeddingModel for BedrockEmbeddingModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn max_embeddings_per_call(&self) -> Option { if is_cohere_embedding_model(&self.model_id) { Some(96) diff --git a/aimux-providers/src/bedrock/image.rs b/aimux-providers/src/bedrock/image.rs index cbebdedba..1be71ed46 100644 --- a/aimux-providers/src/bedrock/image.rs +++ b/aimux-providers/src/bedrock/image.rs @@ -103,9 +103,6 @@ impl ImageModel for BedrockImageModel { fn model_id(&self) -> &str { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } fn max_images_per_call(&self) -> Option { Some(get_max_images_per_call(&self.model_id)) } diff --git a/aimux-providers/src/bedrock/mod.rs b/aimux-providers/src/bedrock/mod.rs index df28bcfdd..b3f927168 100644 --- a/aimux-providers/src/bedrock/mod.rs +++ b/aimux-providers/src/bedrock/mod.rs @@ -23,7 +23,7 @@ mod types; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::provider::Provider; -use aimux_provider_utils::{RetryConfig, without_trailing_slash}; +use aimux_provider_utils::without_trailing_slash; use serde_json::Value; pub use embedding::BedrockEmbeddingModel; @@ -69,8 +69,6 @@ pub struct BedrockProviderConfig { pub auth: BedrockAuth, /// AWS region, used for constructing model ARNs and agent-runtime URLs. pub region: String, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, /// 凭证来源(RFC-0023):`None` = explicit;`Some("env:VAR")` = 环境变量。 pub api_key_source: Option, } @@ -93,7 +91,6 @@ impl BedrockProviderConfig { region: region.clone(), }), region, - retry_config: RetryConfig::default(), api_key_source: None, } } @@ -106,7 +103,6 @@ impl BedrockProviderConfig { base_url, auth: BedrockAuth::BearerToken(token.into()), region, - retry_config: RetryConfig::default(), api_key_source: None, } } @@ -118,13 +114,6 @@ impl BedrockProviderConfig { self } - /// Set the retry configuration. Pass `max_retries: 0` to disable retries. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.retry_config = config; - self - } - /// 标注凭证来源(RFC-0023 回放重建用)。 #[must_use] pub fn with_api_key_source(mut self, source: Option<&str>) -> Self { @@ -215,7 +204,6 @@ impl BedrockProvider { BedrockConfig { base_url: self.config.base_url.clone(), auth: self.config.auth.clone(), - retry_config: self.config.retry_config, api_key_source: self.config.api_key_source.clone(), }, ) @@ -230,7 +218,6 @@ impl BedrockProvider { BedrockConfig { base_url: self.config.base_url.clone(), auth: self.config.auth.clone(), - retry_config: self.config.retry_config, api_key_source: self.config.api_key_source.clone(), }, ) @@ -245,7 +232,6 @@ impl BedrockProvider { BedrockConfig { base_url: self.config.base_url.clone(), auth: self.config.auth.clone(), - retry_config: self.config.retry_config, api_key_source: self.config.api_key_source.clone(), }, ) @@ -269,7 +255,6 @@ impl BedrockProvider { self.config.region.clone(), self.config.auth.clone(), ) - .with_retry_config(self.config.retry_config) } } @@ -296,7 +281,6 @@ impl Provider for BedrockProvider { > { let region = self.config.region.clone(); let auth = self.config.auth.clone(); - let retry_config = self.config.retry_config; Box::pin(async move { let url = format!("https://bedrock.{region}.api.amazonaws.com/foundation-models"); // Sign the request (empty body for GET). @@ -315,7 +299,7 @@ impl Provider for BedrockProvider { use aimux_provider_utils::HttpRequest; // Retry rationale: see `openai::model::execute_list_models`. - let resp = aimux_core::retry::prepare_retries(None, retry_config, None) + let resp = aimux_core::retry::prepare_retries(None, None) .retry(|| { aimux_provider_utils::get_from_api( HttpRequest { diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index b1c0c96ec..c0e3dc181 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -20,7 +20,7 @@ use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usa use serde_json::json; -use aimux_provider_utils::{HttpBody, HttpRequest, RetryConfig}; +use aimux_provider_utils::{HttpBody, HttpRequest}; use super::BedrockAuth; use super::convert::{build_request_body, convert_usage, map_finish_reason}; @@ -92,8 +92,6 @@ pub struct BedrockModel { pub struct BedrockConfig { pub base_url: String, pub auth: BedrockAuth, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, /// 凭证来源(RFC-0023):`None` = explicit;`Some("env:VAR")` = 环境变量。 /// SigV4 记 access-key 来源;BearerToken 记 bearer-token 来源。不存明文。 pub api_key_source: Option, @@ -162,10 +160,6 @@ impl LanguageModel for BedrockModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; // M2b: record identity + credential source + region (encoded in @@ -187,7 +181,6 @@ impl LanguageModel for BedrockModel { profile: None, provider_options: Some(serde_json::json!({ "auth_kind": auth_kind, - "max_retries": self.config.retry_config.max_retries, })), } } diff --git a/aimux-providers/src/bedrock/reranking.rs b/aimux-providers/src/bedrock/reranking.rs index 596581224..0c2bdd38b 100644 --- a/aimux-providers/src/bedrock/reranking.rs +++ b/aimux-providers/src/bedrock/reranking.rs @@ -17,7 +17,7 @@ use aimux_core::reranking_model::{ RerankingResult, }; -use aimux_provider_utils::{HttpBody, HttpRequest, RetryConfig}; +use aimux_provider_utils::{HttpBody, HttpRequest}; use super::BedrockAuth; use super::sigv4::sign_request; @@ -70,7 +70,6 @@ pub struct BedrockRerankingModel { base_url: String, region: String, auth: BedrockAuth, - retry_config: RetryConfig, } impl BedrockRerankingModel { @@ -81,15 +80,9 @@ impl BedrockRerankingModel { base_url, region, auth, - retry_config: RetryConfig::default(), } } - pub(crate) fn with_retry_config(mut self, retry_config: RetryConfig) -> Self { - self.retry_config = retry_config; - self - } - fn endpoint(&self) -> String { format!("{}/rerank", self.base_url) } @@ -135,10 +128,6 @@ impl RerankingModel for BedrockRerankingModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.retry_config - } - async fn do_rerank( &self, options: &RerankingCallOptions, diff --git a/aimux-providers/src/bedrock/sigv4.rs b/aimux-providers/src/bedrock/sigv4.rs index e915d95ca..3112c8bd9 100644 --- a/aimux-providers/src/bedrock/sigv4.rs +++ b/aimux-providers/src/bedrock/sigv4.rs @@ -1,223 +1,7 @@ -//! Simplified AWS Signature Version 4 (SigV4) signer. +//! AWS Signature Version 4 signing for Bedrock and Claude Platform on AWS. //! -//! Implements the core SigV4 signing algorithm for authenticating requests to -//! AWS services (Amazon Bedrock, Claude Platform on AWS). The signer produces -//! the `Authorization` header and supporting `X-Amz-*` headers that AWS -//! expects. -//! -//! Reference: -//! -//! This is a self-contained implementation using only `sha2` + `hmac` (no AWS -//! SDK dependency). It supports: -//! - Static credentials (`access_key_id` + `secret_access_key` + optional -//! `session_token`). -//! - Any AWS service name and region. -//! - POST requests with a JSON body. - -use hmac::{Hmac, Mac}; -use sha2::{Digest, Sha256}; - -/// A set of AWS credentials for SigV4 signing. -#[derive(Debug, Clone)] -pub struct AwsCredentials { - pub access_key_id: String, - pub secret_access_key: String, - /// Optional temporary session token (STS). - pub session_token: Option, - pub region: String, -} - -/// The result of signing a request. -#[derive(Debug, Clone)] -pub struct SignedRequest { - /// All headers to send with the request, including the `Authorization` - /// header and any `X-Amz-*` headers. - pub headers: Vec<(String, String)>, -} - -type HmacSha256 = Hmac; - -/// Sign an HTTP request using AWS SigV4. -/// -/// # Arguments -/// - `credentials` - The AWS credentials to sign with. -/// - `service` - The AWS service name (e.g. `"bedrock"`, `"aws-external-anthropic"`). -/// - `method` - HTTP method (e.g. `"POST"`). -/// - `url` - The full request URL. -/// - `body` - The request body as a string (e.g. JSON). -/// - `extra_headers` - Additional headers to include in the request (these are -/// also included in the canonical headers for signing). -#[must_use] -pub fn sign_request( - credentials: &AwsCredentials, - service: &str, - method: &str, - url: &str, - body: &str, - extra_headers: &[(String, String)], -) -> SignedRequest { - let parsed = - url::Url::parse(url).unwrap_or_else(|_| url::Url::parse("http://localhost").unwrap()); - // The signed host must match the wire host: keep the port whenever the - // URL carries a non-default one (Url::port() is Some only for non-default - // ports). Signing a bare host diverges from the Host header reqwest would - // otherwise so gateways and proxies on non-standard ports reject the - // signature (observed as 502 when an env proxy forwarded a signed - // loopback request whose host lacked the port). - let host = match parsed.port() { - Some(port) => format!("{}:{}", parsed.host_str().unwrap_or(""), port), - None => parsed.host_str().unwrap_or("").to_string(), - }; - let path = parsed.path(); - let query = parsed.query().unwrap_or(""); - - // Timestamp:yyyyMMddTHHmmssZ and date:yyyyMMdd - let now = chrono::Utc::now(); - let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); - let date_stamp = now.format("%Y%m%d").to_string(); - - // Body hash - let body_hash = hex::encode(Sha256::digest(body.as_bytes())); - - // Build canonical headers (sorted, lowercased, trimmed). - // Always include host, x-amz-date, and x-amz-content-sha256. - let mut canonical_headers: Vec<(String, String)> = vec![ - ("host".to_string(), host.to_string()), - ("x-amz-content-sha256".to_string(), body_hash.clone()), - ("x-amz-date".to_string(), amz_date.clone()), - ]; - - if let Some(ref token) = credentials.session_token { - canonical_headers.push(("x-amz-security-token".to_string(), token.clone())); - } - - for (k, v) in extra_headers { - let lower = k.to_lowercase(); - // Don't override the required headers. - if lower != "host" - && lower != "x-amz-date" - && lower != "x-amz-content-sha256" - && lower != "x-amz-security-token" - { - canonical_headers.push((lower, v.trim().to_string())); - } - } - - canonical_headers.sort_by(|a, b| a.0.cmp(&b.0)); - - let canonical_headers_str: String = canonical_headers - .iter() - .map(|(k, v)| format!("{k}:{v}\n")) - .collect(); - let signed_headers: String = canonical_headers - .iter() - .map(|(k, _)| k.as_str()) - .collect::>() - .join(";"); - - // Canonical request - let canonical_request = format!( - "{}\n{}\n{}\n{}\n{}\n{}", - method.to_uppercase(), - path, - query, - canonical_headers_str, - signed_headers, - body_hash - ); - - let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes())); - - // Credential scope - let credential_scope = format!( - "{}/{}/{}/aws4_request", - date_stamp, credentials.region, service - ); - - // String to sign - let string_to_sign = - format!("AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{canonical_request_hash}"); - - // Signing key: derived through chained HMAC - let k_date = hmac_sha256( - date_stamp.as_bytes(), - credentials.secret_access_key.as_bytes(), - ); - let k_region = hmac_sha256(credentials.region.as_bytes(), &k_date); - let k_service = hmac_sha256(service.as_bytes(), &k_region); - let k_signing = hmac_sha256(b"aws4_request", &k_service); - - let signature = hex::encode(hmac_sha256(string_to_sign.as_bytes(), &k_signing)); - - let authorization = format!( - "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", - credentials.access_key_id, credential_scope, signed_headers, signature - ); - - // Build the final header list. - let mut headers: Vec<(String, String)> = canonical_headers - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - headers.push(("Authorization".to_string(), authorization)); - - SignedRequest { headers } -} - -fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { - let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); - mac.update(data); - mac.finalize().into_bytes().to_vec() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn creds() -> AwsCredentials { - AwsCredentials { - access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), - secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), - session_token: None, - region: "us-east-1".to_string(), - } - } - - fn host_header(signed: &SignedRequest) -> String { - signed - .headers - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case("host")) - .map(|(_, v)| v.clone()) - .expect("signed request carries a host header") - } - - #[test] - fn host_keeps_non_default_port() { - let signed = sign_request( - &creds(), - "bedrock", - "POST", - "http://127.0.0.1:54321/model/x/invoke", - "{}", - &[], - ); - assert_eq!(host_header(&signed), "127.0.0.1:54321"); - } +//! The signer lives in `aimux-provider-utils` (`sigv4_fetch`), where it also +//! backs the `SigV4Fetch` transport decorator. The models here still call +//! `sign_request` directly; they move to the decorator in A4. - #[test] - fn host_omits_default_port() { - let signed = sign_request( - &creds(), - "bedrock", - "POST", - "https://bedrock-runtime.us-east-1.amazonaws.com/model/x/invoke", - "{}", - &[], - ); - assert_eq!( - host_header(&signed), - "bedrock-runtime.us-east-1.amazonaws.com" - ); - } -} +pub use aimux_provider_utils::sigv4_fetch::{AwsCredentials, SignedRequest, sign_request}; diff --git a/aimux-providers/src/black_forest_labs.rs b/aimux-providers/src/black_forest_labs.rs index ebfa09af5..1ed8f34ca 100644 --- a/aimux-providers/src/black_forest_labs.rs +++ b/aimux-providers/src/black_forest_labs.rs @@ -346,11 +346,7 @@ impl ImageModel for BlackForestLabsImageModel { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let retries = retry::prepare_retries( - options.max_retries, - self.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); // Poll for result let poll_interval = bfl_opts @@ -408,6 +404,7 @@ impl ImageModel for BlackForestLabsImageModel { trusted_origin: Some(self.config.base_url.clone()), // Headers are gated per URL by gated_headers above. credentialed_origin: None, + fetch: None, }, aimux_provider_utils::create_json_response_handler::(), bfl_failed_response_handler(), @@ -485,6 +482,7 @@ impl ImageModel for BlackForestLabsImageModel { trusted_origin: Some(self.config.base_url.clone()), // Headers are gated per URL by gated_headers above. credentialed_origin: None, + fetch: None, }, aimux_provider_utils::create_binary_response_handler(), bfl_failed_response_handler(), diff --git a/aimux-providers/src/catalogue.rs b/aimux-providers/src/catalogue.rs index 4e18bea4c..33473f40f 100644 --- a/aimux-providers/src/catalogue.rs +++ b/aimux-providers/src/catalogue.rs @@ -13,7 +13,6 @@ use std::collections::HashMap; use std::time::Duration; use aimux_core::AiMuxError; -use aimux_core::ApiCallError; use aimux_core::model_catalogue::{ CatalogueSource, Modality, ModelCapabilities, ModelCost, ModelLimits, ModelModalities, ModelSpec, ModelType, ReasoningMode, ReasoningSpec, ReasoningVisibility, @@ -407,53 +406,22 @@ fn build_cost(m: &Value) -> Option { }) } +/// Whole-exchange bound for the catalogue download (the aggregate file is a +/// few MiB; the helper default is tuned for API calls). +const FETCH_TIMEOUT: Duration = Duration::from_secs(60); + async fn fetch_text(url: &str) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(60)) - .build() - .map_err(|e| { - AiMuxError::ApiCall(Box::new(ApiCallError { - is_retryable: true, - ..ApiCallError::new( - format!("get_model_specs: build client: {e}"), - url, - serde_json::json!({}), - ) - })) - })?; - let resp = client.get(url).send().await.map_err(|e| { - AiMuxError::ApiCall(Box::new(ApiCallError { - is_retryable: true, - ..ApiCallError::new( - format!("get_model_specs: fetch {url}: {e}"), - url, - serde_json::json!({}), - ) - })) - })?; - if !resp.status().is_success() { - let status = resp.status().as_u16(); - return Err(AiMuxError::ApiCall(Box::new(ApiCallError { - status_code: Some(status), - response_body: resp.text().await.ok().filter(|b| !b.is_empty()), - is_retryable: status == 429 || status >= 500, - ..ApiCallError::new( - format!("get_model_specs: fetch {url}"), - url, - serde_json::json!({}), - ) - }))); - } - resp.text().await.map_err(|e| { - AiMuxError::ApiCall(Box::new(ApiCallError { - is_retryable: true, - ..ApiCallError::new( - format!("get_model_specs: read body: {e}"), - url, - serde_json::json!({}), - ) - })) - }) + let output = aimux_provider_utils::get_from_api( + aimux_provider_utils::HttpRequest { + url: url.to_string(), + response_timeout: Some(FETCH_TIMEOUT), + ..Default::default() + }, + aimux_provider_utils::create_binary_response_handler(), + aimux_provider_utils::create_status_code_error_response_handler(), + ) + .await?; + Ok(String::from_utf8_lossy(&output.value).into_owned()) } #[cfg(test)] diff --git a/aimux-providers/src/codex.rs b/aimux-providers/src/codex.rs index ccf99d3e1..34f985cab 100644 --- a/aimux-providers/src/codex.rs +++ b/aimux-providers/src/codex.rs @@ -36,7 +36,7 @@ use aimux_core::provider::Provider; use aimux_core::result::{GenerateResult, StreamResult}; use aimux_core::types::Warning; -use aimux_provider_utils::{HttpRequest, RetryConfig}; +use aimux_provider_utils::HttpRequest; use crate::openai::responses::responses_convert::{ build_header_list, build_responses_event_stream, build_responses_generate_result, @@ -144,13 +144,6 @@ impl CodexConfig { self } - /// Override the retry settings used by Core model operations. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.openai = self.openai.with_retry_config(config); - self - } - /// Extra headers merged into every request (user-supplied values win over /// the subscription-mode defaults). #[must_use] @@ -206,12 +199,8 @@ impl Provider for CodexProvider { let config = self.config.openai.clone(); Box::pin(async move { let headers = crate::openai::model::build_auth_headers(&config); - let runtime = crate::openai::model::execute_list_models( - &config.base_url, - &headers, - config.retry_config, - ) - .await?; + let runtime = + crate::openai::model::execute_list_models(&config.base_url, &headers).await?; Ok(runtime) }) } @@ -441,10 +430,6 @@ impl LanguageModel for CodexModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.openai.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; ProviderRecord { diff --git a/aimux-providers/src/cohere/embedding.rs b/aimux-providers/src/cohere/embedding.rs index 1c21a849f..7aa67b3e0 100644 --- a/aimux-providers/src/cohere/embedding.rs +++ b/aimux-providers/src/cohere/embedding.rs @@ -61,10 +61,6 @@ impl EmbeddingModel for CohereEmbeddingModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn max_embeddings_per_call(&self) -> Option { Some(96) } diff --git a/aimux-providers/src/cohere/mod.rs b/aimux-providers/src/cohere/mod.rs index b4cc75315..d08ed229f 100644 --- a/aimux-providers/src/cohere/mod.rs +++ b/aimux-providers/src/cohere/mod.rs @@ -16,7 +16,7 @@ pub use reranking::CohereRerankingModel; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::provider::Provider; -use aimux_provider_utils::{RetryConfig, load_api_key, without_trailing_slash}; +use aimux_provider_utils::{load_api_key, without_trailing_slash}; use serde_json::Value; pub(crate) fn cohere_failed_response_handler() -> aimux_provider_utils::ResponseHandler @@ -40,8 +40,6 @@ pub struct CohereConfig { pub base_url: String, /// api_key 来源(RFC-0023):`None` = explicit;`Some("env:VAR")` = 环境变量。 pub api_key_source: Option, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, } impl CohereConfig { @@ -51,7 +49,6 @@ impl CohereConfig { api_key: api_key.into(), base_url: "https://api.cohere.com/v2".to_string(), api_key_source: None, - retry_config: RetryConfig::default(), } } @@ -69,13 +66,6 @@ impl CohereConfig { self } - /// Set the retry configuration. Pass `max_retries: 0` to disable retries. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.retry_config = config; - self - } - /// Create from environment variable `COHERE_API_KEY`. /// /// # Errors @@ -152,7 +142,7 @@ impl Provider for CohereProvider { ]; use aimux_provider_utils::HttpRequest; // Retry rationale: see `openai::model::execute_list_models`. - let resp = aimux_core::retry::prepare_retries(None, config.retry_config, None) + let resp = aimux_core::retry::prepare_retries(None, None) .retry(|| { aimux_provider_utils::get_from_api( HttpRequest { diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index dddedb19f..8387154cf 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -98,10 +98,6 @@ impl LanguageModel for CohereModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; ProviderRecord { diff --git a/aimux-providers/src/cohere/reranking.rs b/aimux-providers/src/cohere/reranking.rs index 9a2c91814..03b6df658 100644 --- a/aimux-providers/src/cohere/reranking.rs +++ b/aimux-providers/src/cohere/reranking.rs @@ -102,10 +102,6 @@ impl RerankingModel for CohereRerankingModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - async fn do_rerank( &self, options: &RerankingCallOptions, diff --git a/aimux-providers/src/elevenlabs.rs b/aimux-providers/src/elevenlabs.rs index 8ab7f5e59..c049a93d1 100644 --- a/aimux-providers/src/elevenlabs.rs +++ b/aimux-providers/src/elevenlabs.rs @@ -857,6 +857,7 @@ impl TranscriptionModel for ElevenLabsTranscriptionModel { subprotocols: Vec::new(), abort_signal: options.abort_signal.clone(), timeout: options.timeout, + connector: None, }; let mut ws = ws_connect(&req).await?; diff --git a/aimux-providers/src/fal.rs b/aimux-providers/src/fal.rs index 32460f8fd..e6260a5d2 100644 --- a/aimux-providers/src/fal.rs +++ b/aimux-providers/src/fal.rs @@ -275,6 +275,7 @@ impl TranscriptionModel for FalTranscriptionModel { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, Value::Object(body), aimux_provider_utils::create_json_response_handler(), @@ -283,11 +284,7 @@ impl TranscriptionModel for FalTranscriptionModel { .await?; let job: FalJobResponse = resp.value; - let retries = retry::prepare_retries( - options.max_retries, - self.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); // Poll for result. let raw_body: Value; @@ -315,6 +312,7 @@ impl TranscriptionModel for FalTranscriptionModel { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, aimux_provider_utils::create_json_response_handler::< FalTranscriptionResponse, @@ -582,6 +580,7 @@ impl ImageModel for FalImageModel { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, Value::Object(body), aimux_provider_utils::create_json_response_handler(), @@ -591,11 +590,7 @@ impl ImageModel for FalImageModel { let rh = resp.response_headers; let rb: Value = resp.value; - let retries = retry::prepare_retries( - options.max_retries, - self.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); let target_images: Vec = if let Some(i) = rb.get("images").and_then(|v| v.as_array()) { @@ -626,6 +621,7 @@ impl ImageModel for FalImageModel { validate_url: true, trusted_origin: Some(self.config.base_url.clone()), credentialed_origin: Some(self.config.base_url.clone()), + fetch: None, }, aimux_provider_utils::create_binary_response_handler(), fal_failed_response_handler(), @@ -836,6 +832,7 @@ impl VideoModel for FalVideoModel { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, Value::Object(body), aimux_provider_utils::create_json_response_handler(), @@ -890,6 +887,7 @@ impl VideoModel for FalVideoModel { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, aimux_provider_utils::create_json_response_handler::(), fal_failed_response_handler(), diff --git a/aimux-providers/src/gladia.rs b/aimux-providers/src/gladia.rs index fbaa7af68..17b9e286e 100644 --- a/aimux-providers/src/gladia.rs +++ b/aimux-providers/src/gladia.rs @@ -225,11 +225,7 @@ impl TranscriptionModel for GladiaTranscriptionModel { // retry would re-upload the audio to retry a later stage. An // exhausted inner retry returns `AiMuxError::Retry`, which the outer // retry passes through, so `do_generate` is never replayed (§6.2). - let retries = retry::prepare_retries( - options.max_retries, - self.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); // Step 1: Upload audio. let mut form = MultipartForm::new(); @@ -324,6 +320,7 @@ impl TranscriptionModel for GladiaTranscriptionModel { validate_url: true, trusted_origin: Some(self.config.base_url.clone()), credentialed_origin: Some(self.config.base_url.clone()), + fetch: None, }, aimux_provider_utils::create_json_response_handler::( ), diff --git a/aimux-providers/src/google/embedding.rs b/aimux-providers/src/google/embedding.rs index 4eb061c43..5bcedcb03 100644 --- a/aimux-providers/src/google/embedding.rs +++ b/aimux-providers/src/google/embedding.rs @@ -69,10 +69,6 @@ impl EmbeddingModel for GoogleEmbeddingModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn max_embeddings_per_call(&self) -> Option { Some(100) } diff --git a/aimux-providers/src/google/files.rs b/aimux-providers/src/google/files.rs index d0256391a..a19071b1a 100644 --- a/aimux-providers/src/google/files.rs +++ b/aimux-providers/src/google/files.rs @@ -193,11 +193,7 @@ impl Files for GoogleFiles { // here — per stage, not around the whole upload: a failure in the // upload or poll stage must not replay the init exchange that minted // `upload_url`, nor resend the file body. - let retries = aimux_core::retry::prepare_retries( - None, - self.config.retry_config, - options.abort_signal.clone(), - ); + let retries = aimux_core::retry::prepare_retries(None, options.abort_signal.clone()); let init_resp = retries .retry(|| async { aimux_provider_utils::post_json_to_api( @@ -213,6 +209,7 @@ impl Files for GoogleFiles { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, init_body_value.clone(), aimux_provider_utils::ResponseHandler::new(|input| async move { @@ -277,6 +274,7 @@ impl Files for GoogleFiles { validate_url: true, trusted_origin: Some(self.config.base_url.clone()), credentialed_origin: Some(self.config.base_url.clone()), + fetch: None, }, HttpBody::Bytes(file_bytes.clone(), media_type.clone()), aimux_provider_utils::create_json_response_handler::(), @@ -340,6 +338,7 @@ impl Files for GoogleFiles { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, aimux_provider_utils::create_json_response_handler::(), super::google_failed_response_handler(), diff --git a/aimux-providers/src/google/image.rs b/aimux-providers/src/google/image.rs index 75ff4da8f..37db7730a 100644 --- a/aimux-providers/src/google/image.rs +++ b/aimux-providers/src/google/image.rs @@ -350,10 +350,6 @@ impl ImageModel for GoogleImageModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn max_images_per_call(&self) -> Option { Some(self.max_images()) } diff --git a/aimux-providers/src/google/mod.rs b/aimux-providers/src/google/mod.rs index 0a3fc5765..d0f7621b6 100644 --- a/aimux-providers/src/google/mod.rs +++ b/aimux-providers/src/google/mod.rs @@ -28,7 +28,7 @@ pub use video::GoogleVideoModel; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::provider::Provider; -use aimux_provider_utils::{RetryConfig, load_api_key, without_trailing_slash}; +use aimux_provider_utils::{load_api_key, without_trailing_slash}; use serde_json::Value; pub(crate) fn google_stream_error( @@ -86,8 +86,6 @@ pub struct GoogleConfig { /// (config_snapshot 记为 "explicit");`Some("env:VAR")` = 来自环境变量。 /// 不存明文之外的信息,仅用于回放重建。 pub api_key_source: Option, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, } impl GoogleConfig { @@ -97,7 +95,6 @@ impl GoogleConfig { api_key: api_key.into(), base_url: "https://generativelanguage.googleapis.com/v1beta".to_string(), api_key_source: None, - retry_config: RetryConfig::default(), } } @@ -115,13 +112,6 @@ impl GoogleConfig { self } - /// Set the retry configuration. Pass `max_retries: 0` to disable retries. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.retry_config = config; - self - } - /// Create from the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable. /// /// # Errors @@ -228,7 +218,7 @@ impl Provider for GoogleProvider { use aimux_provider_utils::HttpRequest; // Retry rationale: see `openai::model::execute_list_models`. - let resp = aimux_core::retry::prepare_retries(None, config.retry_config, None) + let resp = aimux_core::retry::prepare_retries(None, None) .retry(|| { aimux_provider_utils::get_from_api( HttpRequest { diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index aaa923a58..da6a6b2ba 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -98,10 +98,6 @@ impl LanguageModel for GoogleModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; ProviderRecord { diff --git a/aimux-providers/src/google/video.rs b/aimux-providers/src/google/video.rs index dce85c6b0..b533de8bc 100644 --- a/aimux-providers/src/google/video.rs +++ b/aimux-providers/src/google/video.rs @@ -69,9 +69,6 @@ impl VideoModel for GoogleVideoModel { fn model_id(&self) -> &str { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } fn max_videos_per_call(&self) -> Option { Some(1) } diff --git a/aimux-providers/src/huggingface.rs b/aimux-providers/src/huggingface.rs index 8cf265a47..40e86e29b 100644 --- a/aimux-providers/src/huggingface.rs +++ b/aimux-providers/src/huggingface.rs @@ -120,12 +120,8 @@ impl Provider for HuggingFaceProvider { let config = self.config.0.clone(); Box::pin(async move { let headers = crate::openai::model::build_auth_headers(&config); - let runtime = crate::openai::model::execute_list_models( - &config.base_url, - &headers, - config.retry_config, - ) - .await?; + let runtime = + crate::openai::model::execute_list_models(&config.base_url, &headers).await?; Ok(runtime) }) } diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index 2c8ccd8c9..72d127d88 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -168,10 +168,6 @@ impl LanguageModel for HuggingFaceResponsesModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.openai_config().retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { // M2b: HuggingFace wraps OpenAIConfig — reuse the OpenAI snapshot helper. crate::openai::config_snapshot_from_config( diff --git a/aimux-providers/src/luma.rs b/aimux-providers/src/luma.rs index 56d8d239f..f1c681f14 100644 --- a/aimux-providers/src/luma.rs +++ b/aimux-providers/src/luma.rs @@ -334,11 +334,7 @@ impl ImageModel for LumaImageModel { AiMuxError::InvalidResponseData("missing id in Luma response".to_string()) })? .to_string(); - let retries = retry::prepare_retries( - options.max_retries, - self.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); // Poll for completion let mut image_url = None; @@ -417,6 +413,7 @@ impl ImageModel for LumaImageModel { validate_url: true, trusted_origin: Some(self.config.base_url.clone()), credentialed_origin: Some(self.config.base_url.clone()), + fetch: None, }, aimux_provider_utils::create_binary_response_handler(), aimux_provider_utils::create_status_code_error_response_handler(), diff --git a/aimux-providers/src/mistral/embedding.rs b/aimux-providers/src/mistral/embedding.rs index 6e1411de3..e795733ad 100644 --- a/aimux-providers/src/mistral/embedding.rs +++ b/aimux-providers/src/mistral/embedding.rs @@ -61,10 +61,6 @@ impl EmbeddingModel for MistralEmbeddingModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn max_embeddings_per_call(&self) -> Option { Some(32) } diff --git a/aimux-providers/src/mistral/mod.rs b/aimux-providers/src/mistral/mod.rs index 7b245b27b..7c7ed028a 100644 --- a/aimux-providers/src/mistral/mod.rs +++ b/aimux-providers/src/mistral/mod.rs @@ -16,7 +16,7 @@ pub use embedding::MistralEmbeddingModel; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::provider::Provider; -use aimux_provider_utils::{RetryConfig, load_api_key, without_trailing_slash}; +use aimux_provider_utils::{load_api_key, without_trailing_slash}; use serde_json::Value; pub(crate) fn mistral_failed_response_handler() -> aimux_provider_utils::ResponseHandler @@ -82,8 +82,6 @@ pub struct MistralConfig { pub base_url: String, /// api_key 来源(RFC-0023):`None` = explicit;`Some("env:VAR")` = 环境变量。 pub api_key_source: Option, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, } impl MistralConfig { @@ -93,7 +91,6 @@ impl MistralConfig { api_key: api_key.into(), base_url: "https://api.mistral.ai/v1".to_string(), api_key_source: None, - retry_config: RetryConfig::default(), } } @@ -111,13 +108,6 @@ impl MistralConfig { self } - /// Set the retry configuration. Pass `max_retries: 0` to disable retries. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.retry_config = config; - self - } - /// Create from environment variable `MISTRAL_API_KEY`. /// /// # Errors @@ -187,7 +177,7 @@ impl Provider for MistralProvider { ]; use aimux_provider_utils::HttpRequest; // Retry rationale: see `openai::model::execute_list_models`. - let resp = aimux_core::retry::prepare_retries(None, config.retry_config, None) + let resp = aimux_core::retry::prepare_retries(None, None) .retry(|| { aimux_provider_utils::get_from_api( HttpRequest { diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 52d63e874..71c977578 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -201,10 +201,6 @@ impl LanguageModel for MistralModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; ProviderRecord { diff --git a/aimux-providers/src/openai/embedding.rs b/aimux-providers/src/openai/embedding.rs index 4a7d0a7b0..a23a1b8e6 100644 --- a/aimux-providers/src/openai/embedding.rs +++ b/aimux-providers/src/openai/embedding.rs @@ -76,10 +76,6 @@ impl EmbeddingModel for OpenAIEmbeddingModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn max_embeddings_per_call(&self) -> Option { Some(2048) } diff --git a/aimux-providers/src/openai/files.rs b/aimux-providers/src/openai/files.rs index 2574cd76c..fc7de23f6 100644 --- a/aimux-providers/src/openai/files.rs +++ b/aimux-providers/src/openai/files.rs @@ -172,11 +172,7 @@ impl Files for OpenAIFiles { // `do_upload_file`), so the retry lives here — safe for the whole // exchange because an upload is not billable and a failed // create-a-file request returns no id to replay against (§9.4). - let retries = aimux_core::retry::prepare_retries( - None, - self.config.retry_config, - options.abort_signal.clone(), - ); + let retries = aimux_core::retry::prepare_retries(None, options.abort_signal.clone()); let resp = retries .retry(|| { aimux_provider_utils::post_to_api( diff --git a/aimux-providers/src/openai/image.rs b/aimux-providers/src/openai/image.rs index ec157c4c5..77396c444 100644 --- a/aimux-providers/src/openai/image.rs +++ b/aimux-providers/src/openai/image.rs @@ -118,10 +118,6 @@ impl ImageModel for OpenAIImageModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn max_images_per_call(&self) -> Option { Some(get_max_images_per_call(&self.model_id)) } diff --git a/aimux-providers/src/openai/mod.rs b/aimux-providers/src/openai/mod.rs index 220758c83..235eb2673 100644 --- a/aimux-providers/src/openai/mod.rs +++ b/aimux-providers/src/openai/mod.rs @@ -27,7 +27,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::provider::Provider; -use aimux_provider_utils::{RetryConfig, load_api_key, without_trailing_slash}; +use aimux_provider_utils::{load_api_key, without_trailing_slash}; use serde_json::Value; pub(crate) fn openai_failed_response_handler() -> aimux_provider_utils::ResponseHandler @@ -192,7 +192,7 @@ fn provider_options_to_json(config: &OpenAIConfig) -> Option headers: config.headers.clone(), organization: config.org_id.clone(), project: config.project.clone(), - max_retries: Some(config.retry_config.max_retries), + max_retries: None, body_overrides: config.body_overrides.clone(), }; serde_json::to_value(opts).ok() @@ -220,8 +220,6 @@ pub struct OpenAIConfig { /// 厂商能力差异描述。默认 `full()`(支持全部能力)。 /// 薄封装用 `with_profile()` 设置差异。 pub profile: OpenAICompatProfile, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, /// Provider 级请求体覆盖(RFC-0017)。在标准请求体 + 内置厂商 override /// 之后 deep-merge。per-call 的 `CallOptions.body_overrides` 在此之后 /// 再 merge(覆盖 provider 级)。 @@ -240,7 +238,6 @@ impl OpenAIConfig { headers: None, provider: "openai".to_string(), profile: OpenAICompatProfile::full(), - retry_config: RetryConfig::default(), body_overrides: None, } } @@ -286,13 +283,6 @@ impl OpenAIConfig { self } - /// Set the retry configuration. Pass `max_retries: 0` to disable retries. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.retry_config = config; - self - } - /// 设置 provider 级请求体覆盖(RFC-0017)。 #[must_use] pub fn with_body_overrides(mut self, overrides: Value) -> Self { @@ -412,8 +402,7 @@ impl Provider for OpenAIProvider { let config = self.config.clone(); Box::pin(async move { let headers = model::build_auth_headers(&config); - let runtime = - model::execute_list_models(&config.base_url, &headers, config.retry_config).await?; + let runtime = model::execute_list_models(&config.base_url, &headers).await?; Ok(runtime) }) } diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 107f984ee..5dfb43d91 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -193,10 +193,6 @@ impl LanguageModel for OpenAIModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { super::config_snapshot_from_config(&self.config.provider, &self.model_id, &self.config) } @@ -819,7 +815,6 @@ struct ModelEntry { pub async fn execute_list_models( base_url: &str, headers: &HashMap, - retry_config: aimux_core::retry::RetryConfig, ) -> Result, AiMuxError> { // Strip a trailing slash so we don't get `//models`. let base = base_url.trim_end_matches('/'); @@ -829,7 +824,7 @@ pub async fn execute_list_models( // retries it — apply the Core retry primitive here (a catalogue GET is // idempotent) so a transient 429/503/transport failure behaves like every // other exchange instead of failing on the first hiccup. - let retries = aimux_core::retry::prepare_retries(None, retry_config, None); + let retries = aimux_core::retry::prepare_retries(None, None); let resp = retries .retry(|| { aimux_provider_utils::get_from_api( diff --git a/aimux-providers/src/openai/responses/mod.rs b/aimux-providers/src/openai/responses/mod.rs index 91afa8978..295854f07 100644 --- a/aimux-providers/src/openai/responses/mod.rs +++ b/aimux-providers/src/openai/responses/mod.rs @@ -111,10 +111,6 @@ impl LanguageModel for OpenAIResponsesModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { super::config_snapshot_from_config(&self.config.provider, &self.model_id, &self.config) } diff --git a/aimux-providers/src/openai/speech.rs b/aimux-providers/src/openai/speech.rs index d1d302415..e77ea3af5 100644 --- a/aimux-providers/src/openai/speech.rs +++ b/aimux-providers/src/openai/speech.rs @@ -83,10 +83,6 @@ impl SpeechModel for OpenAISpeechModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - async fn do_generate(&self, options: &SpeechCallOptions) -> Result { let (body, warnings) = build_request_body_and_warnings(options, &self.model_id)?; diff --git a/aimux-providers/src/openai/transcription.rs b/aimux-providers/src/openai/transcription.rs index b0ca5777c..670dac22f 100644 --- a/aimux-providers/src/openai/transcription.rs +++ b/aimux-providers/src/openai/transcription.rs @@ -266,10 +266,6 @@ impl TranscriptionModel for OpenAITranscriptionModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - async fn do_generate( &self, options: &TranscriptionCallOptions, @@ -492,6 +488,7 @@ impl TranscriptionModel for OpenAITranscriptionModel { subprotocols: Vec::new(), abort_signal: abort.clone(), timeout: options.timeout, + connector: None, }; let mut ws = ws_connect(&req).await?; diff --git a/aimux-providers/src/provider.rs b/aimux-providers/src/provider.rs index 4cb393af4..67d2e6348 100644 --- a/aimux-providers/src/provider.rs +++ b/aimux-providers/src/provider.rs @@ -315,7 +315,6 @@ struct ResolvedEntry { headers: Option>, organization: Option, project: Option, - max_retries: Option, body_overrides: Option, } @@ -332,7 +331,6 @@ impl ResolvedEntry { headers: None, organization: None, project: None, - max_retries: None, body_overrides: None, } } @@ -349,7 +347,6 @@ impl ResolvedEntry { headers: entry.headers.clone(), organization: entry.organization.clone(), project: entry.project.clone(), - max_retries: entry.max_retries, body_overrides: entry.body_overrides.clone(), } } @@ -484,9 +481,8 @@ fn build_resolved_config( if let Some(project) = &entry.project { config = config.with_project(project.clone()); } - if let Some(max_retries) = entry.max_retries { - config.retry_config.max_retries = max_retries; - } + // TODO(A3): an external entry's `max_retries` is no longer read (retry is + // call-level); reject it with InvalidArgument (RFC-0036 D-g). if let Some(overrides) = &entry.body_overrides { config = config.with_body_overrides(overrides.clone()); } @@ -505,9 +501,8 @@ fn build_resolved_config( if let Some(project) = opts.project { config = config.with_project(project); } - if let Some(max_retries) = opts.max_retries { - config.retry_config.max_retries = max_retries; - } + // TODO(A3): `opts.max_retries` is no longer read (retry is + // call-level); reject it with InvalidArgument (RFC-0036 D-g). if let Some(overrides) = opts.body_overrides { config = config.with_body_overrides(overrides); } diff --git a/aimux-providers/src/recraft.rs b/aimux-providers/src/recraft.rs index ab208cbe0..4e53f0765 100644 --- a/aimux-providers/src/recraft.rs +++ b/aimux-providers/src/recraft.rs @@ -275,6 +275,7 @@ async fn extract_images( validate_url: true, trusted_origin: Some(base_url.to_string()), credentialed_origin: Some(base_url.to_string()), + fetch: None, }, aimux_provider_utils::create_binary_response_handler(), recraft_failed_response_handler(), @@ -332,6 +333,7 @@ impl ImageModel for RecraftImageModel { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, Value::Object(body), aimux_provider_utils::create_json_response_handler(), @@ -341,11 +343,7 @@ impl ImageModel for RecraftImageModel { let response_headers = resp.response_headers; let value: Value = resp.value; - let retries = retry::prepare_retries( - options.max_retries, - self.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); let images = extract_images( &value, diff --git a/aimux-providers/src/replay.rs b/aimux-providers/src/replay.rs index 1ee92f107..3a8b633d6 100644 --- a/aimux-providers/src/replay.rs +++ b/aimux-providers/src/replay.rs @@ -81,9 +81,6 @@ pub fn rebuild_provider( if let Some(project) = opts.project { config = config.with_project(project); } - if let Some(max_retries) = opts.max_retries { - config.retry_config.max_retries = max_retries; - } if let Some(overrides) = opts.body_overrides { config = config.with_body_overrides(overrides); } diff --git a/aimux-providers/src/replicate.rs b/aimux-providers/src/replicate.rs index 09eb8b219..db2ed7201 100644 --- a/aimux-providers/src/replicate.rs +++ b/aimux-providers/src/replicate.rs @@ -310,6 +310,7 @@ impl ImageModel for ReplicateImageModel { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, Value::Object(body), aimux_provider_utils::create_json_response_handler(), @@ -319,11 +320,7 @@ impl ImageModel for ReplicateImageModel { let rh = resp.response_headers; let rb: Value = resp.value; - let retries = retry::prepare_retries( - options.max_retries, - self.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); // Extract output (string or array of strings) let urls: Vec = match &rb["output"] { @@ -354,6 +351,7 @@ impl ImageModel for ReplicateImageModel { validate_url: true, trusted_origin: Some(self.config.base_url.clone()), credentialed_origin: Some(self.config.base_url.clone()), + fetch: None, }, aimux_provider_utils::create_binary_response_handler(), replicate_failed_response_handler(), @@ -488,6 +486,7 @@ impl VideoModel for ReplicateVideoModel { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, body, aimux_provider_utils::create_json_response_handler(), @@ -548,6 +547,7 @@ impl VideoModel for ReplicateVideoModel { validate_url: false, trusted_origin: None, credentialed_origin: None, + fetch: None, }, aimux_provider_utils::create_json_response_handler::(), replicate_failed_response_handler(), diff --git a/aimux-providers/src/revai.rs b/aimux-providers/src/revai.rs index 8e2a3c145..caa8e3166 100644 --- a/aimux-providers/src/revai.rs +++ b/aimux-providers/src/revai.rs @@ -265,11 +265,7 @@ impl TranscriptionModel for RevaiTranscriptionModel { ) })?; let submission_language = submit_response.language; - let retries = retry::prepare_retries( - options.max_retries, - self.retry_config(), - options.abort_signal.clone(), - ); + let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); // Poll for completion. let job_status: RevaiJobResponse; diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index f707441e8..559b91058 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -27,7 +27,7 @@ use aimux_core::result::{GenerateResult, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; -use aimux_provider_utils::{HttpRequest, RetryConfig}; +use aimux_provider_utils::HttpRequest; use crate::anthropic::convert::{build_request_body_with_warnings, parse_stop_reason}; use crate::anthropic::stream::{ @@ -59,8 +59,6 @@ pub struct VertexAnthropicConfig { pub auth: VertexAuth, /// 凭证来源(RFC-0023):`None` = explicit;`Some("env:VAR")` = 环境变量。 pub api_key_source: Option, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, } /// An Anthropic Claude language model served via Vertex AI `rawPredict`. @@ -145,10 +143,6 @@ impl LanguageModel for VertexAnthropicModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; // M2b: record identity + credential source + auth kind. Never serialize diff --git a/aimux-providers/src/vertex/embedding.rs b/aimux-providers/src/vertex/embedding.rs index 5ebd732bb..f89e5428a 100644 --- a/aimux-providers/src/vertex/embedding.rs +++ b/aimux-providers/src/vertex/embedding.rs @@ -76,10 +76,6 @@ impl EmbeddingModel for VertexEmbeddingModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn max_embeddings_per_call(&self) -> Option { if uses_embed_content_endpoint(&self.model_id) { Some(1) diff --git a/aimux-providers/src/vertex/image.rs b/aimux-providers/src/vertex/image.rs index f93b59245..22ce922e3 100644 --- a/aimux-providers/src/vertex/image.rs +++ b/aimux-providers/src/vertex/image.rs @@ -408,9 +408,6 @@ impl ImageModel for VertexImageModel { fn model_id(&self) -> &str { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } fn max_images_per_call(&self) -> Option { if is_gemini_model(&self.model_id) { Some(10) diff --git a/aimux-providers/src/vertex/mod.rs b/aimux-providers/src/vertex/mod.rs index 9c828aa70..859a8f47a 100644 --- a/aimux-providers/src/vertex/mod.rs +++ b/aimux-providers/src/vertex/mod.rs @@ -17,7 +17,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::provider::Provider; -use aimux_provider_utils::{RetryConfig, without_trailing_slash}; +use aimux_provider_utils::without_trailing_slash; mod anthropic_model; mod embedding; @@ -55,8 +55,6 @@ pub struct VertexProviderConfig { pub auth: VertexAuth, /// 凭证来源(RFC-0023):`None` = explicit;`Some("env:VAR")` = 环境变量。 pub api_key_source: Option, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, } impl VertexProviderConfig { @@ -81,7 +79,6 @@ impl VertexProviderConfig { location: Some(location), auth: VertexAuth::BearerToken(access_token.into()), api_key_source: None, - retry_config: RetryConfig::default(), } } @@ -93,7 +90,6 @@ impl VertexProviderConfig { location: None, auth: VertexAuth::ApiKey(api_key.into()), api_key_source: None, - retry_config: RetryConfig::default(), } } @@ -111,13 +107,6 @@ impl VertexProviderConfig { self } - /// Set the retry configuration. Pass `max_retries: 0` to disable retries. - #[must_use] - pub fn with_retry_config(mut self, config: RetryConfig) -> Self { - self.retry_config = config; - self - } - /// Create from environment variables. /// /// Checks for `GOOGLE_VERTEX_API_KEY` (Express mode) first, then falls @@ -210,7 +199,6 @@ impl VertexProvider { base_url: self.config.base_url.clone(), auth: self.config.auth.clone(), api_key_source: self.config.api_key_source.clone(), - retry_config: self.config.retry_config, }, )) } @@ -241,7 +229,6 @@ impl VertexProvider { base_url, auth: self.config.auth.clone(), api_key_source: self.config.api_key_source.clone(), - retry_config: self.config.retry_config, }, )) } @@ -256,7 +243,6 @@ impl VertexProvider { base_url: self.config.base_url.clone(), auth: self.config.auth.clone(), api_key_source: self.config.api_key_source.clone(), - retry_config: self.config.retry_config, }, ) } @@ -271,7 +257,6 @@ impl VertexProvider { base_url: self.config.base_url.clone(), auth: self.config.auth.clone(), api_key_source: self.config.api_key_source.clone(), - retry_config: self.config.retry_config, }, ) } @@ -302,8 +287,7 @@ impl VertexProvider { location, self.config.auth.clone(), self.config.base_url.clone(), - ) - .with_retry_config(self.config.retry_config)) + )) } /// Create a video generation model instance for the given Vertex AI model @@ -331,8 +315,7 @@ impl VertexProvider { location, self.config.auth.clone(), self.config.base_url.clone(), - ) - .with_retry_config(self.config.retry_config)) + )) } } @@ -358,7 +341,6 @@ impl Provider for VertexProvider { > { let base_url = self.config.base_url.clone(); let auth = self.config.auth.clone(); - let retry_config = self.config.retry_config; Box::pin(async move { let base = base_url.trim_end_matches('/'); let url = format!("{base}/models"); @@ -375,7 +357,7 @@ impl Provider for VertexProvider { use aimux_provider_utils::HttpRequest; // Retry rationale: see `openai::model::execute_list_models`. - let resp = aimux_core::retry::prepare_retries(None, retry_config, None) + let resp = aimux_core::retry::prepare_retries(None, None) .retry(|| { aimux_provider_utils::get_from_api( HttpRequest { diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index e151adfdb..2cd4a15b4 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -17,7 +17,7 @@ use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; -use aimux_provider_utils::{HttpRequest, RetryConfig}; +use aimux_provider_utils::HttpRequest; use crate::google::convert::{ build_vertex_request_body, code_execution_tool_name, convert_usage, extract_sources, @@ -34,8 +34,6 @@ pub struct VertexConfig { pub auth: VertexAuth, /// 凭证来源(RFC-0023):`None` = explicit;`Some("env:VAR")` = 环境变量。 pub api_key_source: Option, - /// Retry settings used by Core model operations. - pub retry_config: RetryConfig, } /// A Google Vertex AI language model. @@ -124,10 +122,6 @@ impl LanguageModel for VertexModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { use aimux_core::recording::ProviderRecord; // M2b: record identity + credential source + auth kind. Never serialize diff --git a/aimux-providers/src/vertex/transcription.rs b/aimux-providers/src/vertex/transcription.rs index 14f8e8a30..16b3e6e6e 100644 --- a/aimux-providers/src/vertex/transcription.rs +++ b/aimux-providers/src/vertex/transcription.rs @@ -22,7 +22,7 @@ use aimux_core::transcription_model::{ AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest, TranscriptionResponse, TranscriptionResult, TranscriptionSegment, }; -use aimux_provider_utils::{HttpRequest, RetryConfig}; +use aimux_provider_utils::HttpRequest; use super::VertexAuth; @@ -105,7 +105,6 @@ pub struct VertexTranscriptionModel { location: String, auth: VertexAuth, base_url: String, - retry_config: RetryConfig, } impl VertexTranscriptionModel { @@ -123,15 +122,9 @@ impl VertexTranscriptionModel { location, auth, base_url, - retry_config: RetryConfig::default(), } } - pub(crate) fn with_retry_config(mut self, retry_config: RetryConfig) -> Self { - self.retry_config = retry_config; - self - } - fn auth_header(&self) -> HashMap { let mut headers = HashMap::new(); match &self.auth { @@ -188,10 +181,6 @@ impl TranscriptionModel for VertexTranscriptionModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.retry_config - } - async fn do_generate( &self, options: &TranscriptionCallOptions, diff --git a/aimux-providers/src/vertex/video.rs b/aimux-providers/src/vertex/video.rs index a5139f4cf..705344524 100644 --- a/aimux-providers/src/vertex/video.rs +++ b/aimux-providers/src/vertex/video.rs @@ -19,7 +19,7 @@ use aimux_core::video_model::{ VideoResponse, VideoResult, }; -use aimux_provider_utils::{HttpRequest, RetryConfig}; +use aimux_provider_utils::HttpRequest; use super::VertexAuth; @@ -33,7 +33,6 @@ pub struct VertexVideoModel { location: String, auth: VertexAuth, base_url: String, - retry_config: RetryConfig, } impl VertexVideoModel { @@ -51,15 +50,9 @@ impl VertexVideoModel { location, auth, base_url, - retry_config: RetryConfig::default(), } } - pub(crate) fn with_retry_config(mut self, retry_config: RetryConfig) -> Self { - self.retry_config = retry_config; - self - } - fn build_headers(&self, extra: Option<&HashMap>) -> HashMap { let mut h = HashMap::new(); match &self.auth { @@ -114,9 +107,6 @@ impl VideoModel for VertexVideoModel { fn model_id(&self) -> &str { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.retry_config - } fn max_videos_per_call(&self) -> Option { Some(1) } diff --git a/aimux-providers/src/xai/mod.rs b/aimux-providers/src/xai/mod.rs index 104ea0396..e98e52465 100644 --- a/aimux-providers/src/xai/mod.rs +++ b/aimux-providers/src/xai/mod.rs @@ -320,14 +320,10 @@ impl Provider for XAIProvider { let config = OpenAIConfig::new(self.config.api_key()) .with_base_url(self.config.base_url()) .with_provider(PROVIDER_NAME); - // The freshly built OpenAIConfig carries the default retry settings — - // use the user's configured ones from the wrapped config instead. - let retry_config = self.config.0.retry_config; Box::pin(async move { let headers = crate::openai::model::build_auth_headers(&config); let runtime = - crate::openai::model::execute_list_models(&config.base_url, &headers, retry_config) - .await?; + crate::openai::model::execute_list_models(&config.base_url, &headers).await?; Ok(runtime) }) } diff --git a/aimux-providers/src/xai/model.rs b/aimux-providers/src/xai/model.rs index 99cce1322..f14367c0f 100644 --- a/aimux-providers/src/xai/model.rs +++ b/aimux-providers/src/xai/model.rs @@ -92,10 +92,6 @@ impl LanguageModel for XaiModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.openai_config().retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { // M2b: xAI wraps OpenAIConfig — reuse the OpenAI snapshot helper with // xAI's own provider name. api_key_source/profile come from the inner diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index e8beb3682..c7f81b62b 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -89,10 +89,6 @@ impl LanguageModel for XaiResponsesModel { &self.model_id } - fn retry_config(&self) -> aimux_core::retry::RetryConfig { - self.config.openai_config().retry_config - } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { // M2b: reuse the OpenAI snapshot helper with xAI's provider name. crate::openai::config_snapshot_from_config( diff --git a/aimux-providers/tests/anthropic_aws_model_test.rs b/aimux-providers/tests/anthropic_aws_model_test.rs index 8b618fdb8..09d52e82e 100644 --- a/aimux-providers/tests/anthropic_aws_model_test.rs +++ b/aimux-providers/tests/anthropic_aws_model_test.rs @@ -46,7 +46,6 @@ fn make_model(server: &MockServer) -> AnthropicAwsModel { api_version: "2023-06-01".to_string(), workspace_id: None, api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), }, ) } @@ -411,7 +410,6 @@ async fn anthropic_aws_sigv4_auth() { api_version: "2023-06-01".to_string(), workspace_id: None, api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), }, ); diff --git a/aimux-providers/tests/bedrock_embedding_test.rs b/aimux-providers/tests/bedrock_embedding_test.rs index 3ccce5bd9..3bc21aa3c 100644 --- a/aimux-providers/tests/bedrock_embedding_test.rs +++ b/aimux-providers/tests/bedrock_embedding_test.rs @@ -29,7 +29,6 @@ fn test_provider(base_url: String) -> BedrockProvider { base_url, auth: BedrockAuth::BearerToken("test-auth".to_string()), region: "us-east-1".to_string(), - retry_config: aimux_provider_utils::RetryConfig::default(), api_key_source: None, }; BedrockProvider::new(config) diff --git a/aimux-providers/tests/bedrock_model_test.rs b/aimux-providers/tests/bedrock_model_test.rs index eebc91f39..c4f391333 100644 --- a/aimux-providers/tests/bedrock_model_test.rs +++ b/aimux-providers/tests/bedrock_model_test.rs @@ -43,7 +43,6 @@ fn make_model(server: &MockServer) -> BedrockModel { aimux_providers::bedrock::BedrockConfig { base_url: server.uri(), auth: aimux_providers::bedrock::BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), api_key_source: None, }, ) @@ -424,7 +423,6 @@ async fn bedrock_sigv4_auth() { region: "us-east-1".to_string(), }, ), - retry_config: aimux_provider_utils::RetryConfig::default(), api_key_source: None, }, ); diff --git a/aimux-providers/tests/bedrock_reasoning_test.rs b/aimux-providers/tests/bedrock_reasoning_test.rs index f5716eb2e..7cd4b2291 100644 --- a/aimux-providers/tests/bedrock_reasoning_test.rs +++ b/aimux-providers/tests/bedrock_reasoning_test.rs @@ -84,7 +84,6 @@ fn make_model(server: &MockServer) -> BedrockModel { BedrockConfig { base_url: server.uri(), auth: BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), api_key_source: None, }, ) diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs index c17fbb90c..0a6a6bc7b 100644 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ b/aimux-providers/tests/bedrock_remaining_test.rs @@ -62,7 +62,6 @@ fn make_model(server: &MockServer) -> BedrockModel { BedrockConfig { base_url: server.uri(), auth: BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), api_key_source: None, }, ) @@ -591,7 +590,6 @@ async fn arn_model_id_encoded_generate_route() { BedrockConfig { base_url: server.uri(), auth: BedrockAuth::BearerToken("tok".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), api_key_source: None, }, ); @@ -645,7 +643,6 @@ async fn arn_model_id_encoded_stream_route() { BedrockConfig { base_url: server.uri(), auth: BedrockAuth::BearerToken("tok".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), api_key_source: None, }, ); diff --git a/aimux-providers/tests/config_snapshot_test.rs b/aimux-providers/tests/config_snapshot_test.rs index 54a1f49ed..43e3c4023 100644 --- a/aimux-providers/tests/config_snapshot_test.rs +++ b/aimux-providers/tests/config_snapshot_test.rs @@ -76,7 +76,7 @@ fn google_api_key_source_env_vs_explicit() { /// Anthropic `from_env` → `env:ANTHROPIC_API_KEY`; `new` → `explicit`. Also /// covers C6: the snapshot's `provider_options` is a ProviderOptions-shaped -/// object carrying `headers`/`api_version`/`max_retries`/`body_overrides`. +/// object carrying `headers`/`api_version`/`body_overrides`. #[test] fn anthropic_api_key_source_and_snapshot_shape() { let explicit = AnthropicConfig::new("a-explicit"); @@ -102,8 +102,8 @@ fn anthropic_api_key_source_and_snapshot_shape() { assert!(opts.get("headers").is_some(), "missing headers: {opts}"); assert_eq!(opts["api_version"], "2023-06-01"); assert!( - opts.get("max_retries").is_some(), - "missing max_retries: {opts}" + opts.get("max_retries").is_none(), + "retry is call-level and must not be recorded as provider config: {opts}" ); assert!( opts.get("body_overrides").is_some(), diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index 3810d3c05..d63a1c316 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -1311,7 +1311,6 @@ fn bedrock_at(uri: &str, model: &str) -> BedrockModel { BedrockConfig { base_url: uri.to_string(), auth: BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), api_key_source: None, }, ) diff --git a/aimux-providers/tests/google_files_test.rs b/aimux-providers/tests/google_files_test.rs index 17f6a132b..a7fb306ac 100644 --- a/aimux-providers/tests/google_files_test.rs +++ b/aimux-providers/tests/google_files_test.rs @@ -15,7 +15,6 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData}; -use aimux_core::retry::RetryConfig; use aimux_core::shared::{FileBytes, SharedProviderOptions}; use aimux_providers::{GoogleConfig, GoogleProvider}; @@ -37,16 +36,8 @@ fn default_file_resource() -> Value { } fn provider(server: &MockServer) -> GoogleProvider { - provider_with_retries(server, RetryConfig::default().max_retries) -} - -fn provider_with_retries(server: &MockServer, max_retries: u32) -> GoogleProvider { - let config = GoogleConfig::new("test-api-key") - .with_base_url(format!("{}/v1beta", server.uri())) - .with_retry_config(RetryConfig { - max_retries, - ..Default::default() - }); + let config = + GoogleConfig::new("test-api-key").with_base_url(format!("{}/v1beta", server.uri())); GoogleProvider::new(config) } @@ -406,12 +397,17 @@ async fn should_throw_when_initiation_request_fails() { Mock::given(method("POST")) .and(path("/upload/v1beta/files")) - .respond_with(ResponseTemplate::new(500).set_body_string("Init error")) + .respond_with( + ResponseTemplate::new(500) + // Only the message text matters here; a zero retry hint keeps + // the default retries instant. + .insert_header("retry-after-ms", "0") + .set_body_string("Init error"), + ) .mount(&server) .await; - // Only the message text matters here; skip the retries so it stays fast. - let provider = provider_with_retries(&server, 0); + let provider = provider(&server); let files = provider.files(); let result = files.upload_file(&upload_options()).await; @@ -462,12 +458,17 @@ async fn should_throw_when_upload_request_fails() { Mock::given(method("POST")) .and(path("/resume")) - .respond_with(ResponseTemplate::new(500).set_body_string("Upload error")) + .respond_with( + ResponseTemplate::new(500) + // Only the message text matters here; a zero retry hint keeps + // the default retries instant. + .insert_header("retry-after-ms", "0") + .set_body_string("Upload error"), + ) .mount(&server) .await; - // Only the message text matters here; skip the retries so it stays fast. - let provider = provider_with_retries(&server, 0); + let provider = provider(&server); let files = provider.files(); let result = files.upload_file(&upload_options()).await; @@ -633,7 +634,7 @@ async fn transient_upload_failure_is_retried_without_re_initiating() { .mount(&server) .await; - let provider = provider_with_retries(&server, 1); + let provider = provider(&server); let files = provider.files(); let result = files.upload_file(&upload_options()).await.unwrap(); diff --git a/aimux-providers/tests/google_video_test.rs b/aimux-providers/tests/google_video_test.rs index a965f646d..49f89df26 100644 --- a/aimux-providers/tests/google_video_test.rs +++ b/aimux-providers/tests/google_video_test.rs @@ -94,12 +94,7 @@ async fn poll_retry_does_not_submit_a_second_generation() { .mount(&server) .await; - let config = GoogleConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(aimux_provider_utils::RetryConfig { - max_retries: 1, - ..Default::default() - }); + let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); let provider = GoogleProvider::new(config); let model = provider.video("veo-3.0-generate-001"); let mut opts = options("A cat"); @@ -164,12 +159,7 @@ async fn start_retry_reuses_the_same_idempotency_key() { .mount(&server) .await; - let config = GoogleConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(aimux_provider_utils::RetryConfig { - max_retries: 1, - ..Default::default() - }); + let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); let provider = GoogleProvider::new(config); let model = provider.video("veo-3.0-generate-001"); let mut opts = options("A cat"); @@ -253,16 +243,13 @@ async fn poll_retry_exhaustion_does_not_submit_a_second_generation() { .mount(&server) .await; - let config = GoogleConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(aimux_provider_utils::RetryConfig { - max_retries: 1, - ..Default::default() - }); + let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); let provider = GoogleProvider::new(config); let model = provider.video("veo-3.0-generate-001"); - let error = generate_video(&model, options("A cat")).await.unwrap_err(); + let mut opts = options("A cat"); + opts.max_retries = Some(1); + let error = generate_video(&model, opts).await.unwrap_err(); assert!(matches!(error, AiMuxError::Retry(_))); let requests = server.received_requests().await.unwrap(); diff --git a/aimux-providers/tests/list_models_test.rs b/aimux-providers/tests/list_models_test.rs index 67a7326de..2dffd5c1d 100644 --- a/aimux-providers/tests/list_models_test.rs +++ b/aimux-providers/tests/list_models_test.rs @@ -204,16 +204,14 @@ async fn list_models_http_error() { Mock::given(method("GET")) .and(path("/v1/models")) .respond_with( - ResponseTemplate::new(500).set_body_bytes(br#"{"error":"internal"}"#.to_vec()), + ResponseTemplate::new(500) + // A zero retry hint keeps the default retries instant. + .insert_header("retry-after-ms", "0") + .set_body_bytes(br#"{"error":"internal"}"#.to_vec()), ) .mount(&server) .await; - let config = OpenAIConfig::new("test-key") - .with_base_url(format!("{}/v1", server.uri())) - .with_retry_config(aimux_provider_utils::RetryConfig { - max_retries: 0, - ..Default::default() - }); + let config = OpenAIConfig::new("test-key").with_base_url(format!("{}/v1", server.uri())); let provider = OpenAIProvider::new(config); let err = provider.list_models().await.unwrap_err(); assert!(!err.to_string().is_empty()); diff --git a/aimux-providers/tests/openai_files_test.rs b/aimux-providers/tests/openai_files_test.rs index ab503b3ba..8edc7913c 100644 --- a/aimux-providers/tests/openai_files_test.rs +++ b/aimux-providers/tests/openai_files_test.rs @@ -281,8 +281,8 @@ async fn should_set_specification_version_and_provider() { } /// A transient 503 followed by 200 must succeed: `upload_file` retries the -/// upload exchange using the provider's configured retry settings, the same -/// way `list_models` already does. +/// upload exchange with the default retry settings, the same way +/// `list_models` already does. #[tokio::test] async fn transient_failure_is_retried_and_succeeds() { let server = MockServer::start().await; @@ -302,12 +302,7 @@ async fn transient_failure_is_retried_and_succeeds() { .mount(&server) .await; - let config = OpenAIConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(aimux_core::retry::RetryConfig { - max_retries: 1, - ..Default::default() - }); + let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); let provider = OpenAIProvider::new(config); let files = provider.files(); diff --git a/aimux-providers/tests/openai_model_test.rs b/aimux-providers/tests/openai_model_test.rs index abe140674..15b2d2137 100644 --- a/aimux-providers/tests/openai_model_test.rs +++ b/aimux-providers/tests/openai_model_test.rs @@ -1446,8 +1446,6 @@ mod do_stream { #[tokio::test] async fn first_sse_body_transport_error_enters_core_retry() { - use std::time::Duration; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; @@ -1511,15 +1509,8 @@ mod do_stream { } }); - let provider = OpenAIProvider::new( - OpenAIConfig::new("test-api-key") - .with_base_url(base_url) - .with_retry_config(aimux_core::retry::RetryConfig { - max_retries: 1, - initial_delay: Duration::ZERO, - backoff_factor: 2, - }), - ); + let provider = + OpenAIProvider::new(OpenAIConfig::new("test-api-key").with_base_url(base_url)); let result = stream_text( &provider.model("gpt-3.5-turbo"), "Hello", diff --git a/aimux-providers/tests/provider_error_test.rs b/aimux-providers/tests/provider_error_test.rs index a4a2857cc..4864342ae 100644 --- a/aimux-providers/tests/provider_error_test.rs +++ b/aimux-providers/tests/provider_error_test.rs @@ -30,7 +30,6 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::stream_part::StreamPart; -use aimux_provider_utils::RetryConfig; use aimux_providers::anthropic::{AnthropicConfig, AnthropicProvider}; use aimux_providers::openai::{OpenAIConfig, OpenAIProvider}; use futures::StreamExt; @@ -79,12 +78,7 @@ mod openai_generate_errors { use super::*; fn model(server: &MockServer) -> impl LanguageModel { - let config = OpenAIConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(RetryConfig { - max_retries: 0, - ..Default::default() - }); + let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); OpenAIProvider::new(config).model("gpt-4o") } @@ -222,12 +216,7 @@ mod openai_stream_errors { use super::*; fn model(server: &MockServer) -> impl LanguageModel { - let config = OpenAIConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(RetryConfig { - max_retries: 0, - ..Default::default() - }); + let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); OpenAIProvider::new(config).model("gpt-4o") } @@ -345,12 +334,7 @@ mod anthropic_generate_errors { use super::*; fn model(server: &MockServer) -> impl LanguageModel { - let config = AnthropicConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(RetryConfig { - max_retries: 0, - ..Default::default() - }); + let config = AnthropicConfig::new("test-api-key").with_base_url(server.uri()); AnthropicProvider::new(config).model("claude-3-haiku-20240307") } @@ -479,12 +463,7 @@ mod anthropic_stream_errors { use super::*; fn model(server: &MockServer) -> impl LanguageModel { - let config = AnthropicConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(RetryConfig { - max_retries: 0, - ..Default::default() - }); + let config = AnthropicConfig::new("test-api-key").with_base_url(server.uri()); AnthropicProvider::new(config).model("claude-3-haiku-20240307") } diff --git a/aimux-providers/tests/vertex_anthropic_test.rs b/aimux-providers/tests/vertex_anthropic_test.rs index 65e4798ef..b4dbc8cfd 100644 --- a/aimux-providers/tests/vertex_anthropic_test.rs +++ b/aimux-providers/tests/vertex_anthropic_test.rs @@ -61,7 +61,6 @@ fn make_model(server: &MockServer) -> VertexAnthropicModel { location: Some("us-central1".to_string()), auth: VertexAuth::BearerToken("test-token".to_string()), api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), }; VertexProvider::new(config) .anthropic_model(MODEL_ID) diff --git a/aimux-providers/tests/vertex_embedding_test.rs b/aimux-providers/tests/vertex_embedding_test.rs index 856c7dd54..fd1de8ac1 100644 --- a/aimux-providers/tests/vertex_embedding_test.rs +++ b/aimux-providers/tests/vertex_embedding_test.rs @@ -80,7 +80,6 @@ fn test_provider(base_url: String) -> VertexProvider { location: Some("us-central1".to_string()), auth: VertexAuth::BearerToken("test-token".to_string()), api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), }; VertexProvider::new(config) } @@ -295,7 +294,6 @@ fn gemini_embedding_2_max_per_call() { location: Some("us-central1".to_string()), auth: VertexAuth::BearerToken("test".to_string()), api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), }; let provider = VertexProvider::new(config); let model = provider.embedding_model("gemini-embedding-2"); diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs index 295562172..a4d46b779 100644 --- a/aimux-providers/tests/vertex_model_test.rs +++ b/aimux-providers/tests/vertex_model_test.rs @@ -49,7 +49,6 @@ fn make_model(server: &MockServer) -> VertexModel { base_url: server.uri(), auth: VertexAuth::BearerToken("test-token".to_string()), api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), }, ) } @@ -554,7 +553,6 @@ async fn vertex_api_key_auth() { base_url: server.uri(), auth: VertexAuth::ApiKey("test-api-key".to_string()), api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), }, ); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 9ad3d24d8..9b727d44d 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -483,7 +483,10 @@ export interface ProviderConfig { organization?: string /** OpenAI project ID (sent via `OpenAI-Project` header). */ project?: string - /** Override the provider's retry count. `0` disables retries. */ + /** + * No longer applied: retry is a per-call setting (`maxRetries` in the + * call options). TODO(A5): remove this field and reject it. + */ maxRetries?: number /** * Provider-level request body overrides as a JSON string (deep-merged diff --git a/bindings/node/src/error.rs b/bindings/node/src/error.rs index bea1e27be..4b47131cb 100644 --- a/bindings/node/src/error.rs +++ b/bindings/node/src/error.rs @@ -183,6 +183,9 @@ fn aimux_error_class_name(error: &AiMuxError) -> &'static str { AiMuxError::ToolCallRepair { .. } => "ToolCallRepairError", AiMuxError::InvalidArgument(_) => "InvalidArgumentError", AiMuxError::InvalidPrompt(_) => "InvalidPromptError", + // TODO(A5): dedicated LoadAPIKeyError / LoadSettingError classes; until + // then these surface as the existing invalid-argument class. + AiMuxError::LoadApiKey { .. } | AiMuxError::LoadSetting { .. } => "InvalidArgumentError", AiMuxError::TokenExpired(_) => "TokenExpiredError", AiMuxError::UnsupportedFunctionality(_) => "UnsupportedFunctionalityError", AiMuxError::NoSuchModel { .. } => "NoSuchModelError", diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index bf1f0ad30..dd1afae21 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -614,7 +614,8 @@ pub struct ProviderConfig { pub organization: Option, /// OpenAI project ID (sent via `OpenAI-Project` header). pub project: Option, - /// Override the provider's retry count. `0` disables retries. + /// No longer applied: retry is a per-call setting (`maxRetries` in the + /// call options). TODO(A5): remove this field and reject it. pub max_retries: Option, /// Provider-level request body overrides as a JSON string (deep-merged /// into every request). Per-call `bodyOverrides` in GenerateTextOptions @@ -642,9 +643,6 @@ fn apply_provider_config_openai( if let Some(ref proj) = cfg.project { config = config.with_project(proj); } - if let Some(max) = cfg.max_retries { - config.retry_config.max_retries = max; - } if let Some(ref json_str) = cfg.body_overrides { let overrides: serde_json::Value = parse_wire_json("config.bodyOverrides", json_str)?; config = config.with_body_overrides(overrides); @@ -1007,9 +1005,6 @@ pub async fn anthropic( parse_wire_json("config.headers", json_str)?; cfg = cfg.with_headers(h); } - if let Some(max) = opts.max_retries { - cfg.retry_config.max_retries = max; - } if let Some(ref json_str) = opts.body_overrides { let overrides: serde_json::Value = parse_wire_json("config.bodyOverrides", json_str)?; diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index 0dfbed7f1..4bf853e9f 100644 --- a/bindings/node/src/types/AiMuxError.ts +++ b/bindings/node/src/types/AiMuxError.ts @@ -14,7 +14,23 @@ export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | * Original argument text, when supplied by the provider. Absent in * older serialized errors and errors constructed without a call. */ -tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, +tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "LoadApiKey": { +/** + * The environment variable consulted as the fallback. + */ +env_var: string, +/** + * What the key is for, e.g. `"OpenAI"`. + */ +description: string, } } | { "LoadSetting": { +/** + * The environment variable consulted as the fallback. + */ +env_var: string, +/** + * The setting's parameter name, e.g. `"region"`. + */ +name: string, } } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, /** * What kind of model was requested (`"languageModel"`, * `"imageModel"`, …), the AI SDK's `modelType`. diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs index be2165d5c..0665a2d43 100644 --- a/bindings/python/src/error.rs +++ b/bindings/python/src/error.rs @@ -246,6 +246,11 @@ fn variant_instance<'py>(py: Python<'py>, e: &AiMuxError) -> PyResult py.get_type_bound::(), AiMuxError::InvalidArgument(_) => py.get_type_bound::(), AiMuxError::InvalidPrompt(_) => py.get_type_bound::(), + // TODO(A5): dedicated LoadAPIKeyError / LoadSettingError classes; until + // then these surface as the existing invalid-argument class. + AiMuxError::LoadApiKey { .. } | AiMuxError::LoadSetting { .. } => { + py.get_type_bound::() + } AiMuxError::TokenExpired(_) => py.get_type_bound::(), AiMuxError::UnsupportedFunctionality(_) => { py.get_type_bound::() diff --git a/tools/aimux-web/src/api/mod.rs b/tools/aimux-web/src/api/mod.rs index b8beed577..cb74c3ca0 100644 --- a/tools/aimux-web/src/api/mod.rs +++ b/tools/aimux-web/src/api/mod.rs @@ -59,6 +59,8 @@ pub fn err_response(e: AiMuxError) -> Response { let status = match &e { AiMuxError::InvalidArgument(_) | AiMuxError::InvalidPrompt(_) + | AiMuxError::LoadApiKey { .. } + | AiMuxError::LoadSetting { .. } | AiMuxError::NoSuchProvider { .. } | AiMuxError::NoSuchModel { .. } => StatusCode::BAD_REQUEST, AiMuxError::UnsupportedFunctionality(_) => StatusCode::NOT_IMPLEMENTED, From 4b5d5efa6bf68ab18c77ccb4d1f10868d7611598 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:23:55 +0000 Subject: [PATCH 03/72] refactor(core)!: ProviderV4-shaped Provider trait, identity-only recording, and the boundary gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second commit group of the provider-factory rewrite (docs/aisdk-architecture-alignment.md §3.1, §3.3, §4.3, §7). Provider trait (aimux-core/src/provider.rs) - `Provider` now has the `ProviderV4` shape: language / embedding / image are required and return `NoSuchModel { model_type }` when a vendor has no such modality; transcription / speech / reranking / files are optional (`None` = not offered); video and search stay as aimux extensions. All constructors return `Arc`. - `Provider::name()` is gone: the registered name belongs to whoever holds the registry, and each model reports `provider()` itself. - `specification_version()` is removed from the nine model traits and from `TraceLayer`; `LanguageModel::config_snapshot()` is removed and `supported_urls()` added. - `list_models` moves to a separate `ProviderDiscovery` trait (no `ProviderV4` equivalent; most vendors cannot list). `delegate_list_models!` emits that impl; `provider_discovery(name, ..)` resolves a discovery handle for FFI / Node / Python. Recording (RECORDING_SCHEMA = 3) - `ProviderRecord` is identity only: `provider_id`, `provider`, `model_id`. Configuration (base URL, key source, profile, provider options, retry) is no longer recorded; schema-2 files are rejected on deserialization. - `rebuild_provider` rebuilds from `provider_id` + `model_id` through the registry; `is_openai_compatible_provider` is deleted. Native-protocol packages return `NoSuchProvider` until they join the registry (A4). Call-level overrides - `CallOptions.body_overrides` is deleted everywhere (core, openai and anthropic converters, FFI, Node, Python, aimux-web playground, wire fixture). Provider-level overrides are applied to the finished body until `transform_request_body` replaces them (A3). - `ProviderOptions` / `config_json` / Node `ProviderConfig` reject `max_retries` and `body_overrides` with `InvalidArgument` instead of silently ignoring them. Boundary gate - `scripts/check_provider_boundaries.sh` (wired into the contract-tests CI job) fails on provider code reading `options.max_retries` / `timeout` / `session_id` / `call_id` outside `HttpRequest::new`, and on any removed name coming back. Eleven hand-built `HttpRequest` literals now go through `HttpRequest::new`; the eight poll-loop providers use `prepare_retries(None, ..)` constants for their status GETs. Tests: new `provider_trait_test.rs` in core and providers (shape, error mapping, discovery); `config_snapshot_test.rs` deleted; overlay test now proves the override through a wiremock request; body-override tests exercise the provider-level path; Node `provider_config.test.ts` covers the rejection. Not yet done here (later groups): `api_key_source` fields and `ExternalProviderEntry.max_retries` / `body_overrides` still exist unread (A3); `provider_id` is the first segment of `provider()` until the registry name fills it in (A3); Go / Java / Kotlin / Swift / Flutter still declare a call-level `body_overrides` field that core ignores (A5). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- .github/workflows/ci.yml | 2 + aimux-core/Cargo.toml | 1 + aimux-core/src/embedding_model.rs | 5 - aimux-core/src/error.rs | 12 + aimux-core/src/files_model.rs | 5 - aimux-core/src/generate.rs | 15 +- aimux-core/src/image_model.rs | 5 - aimux-core/src/language_model.rs | 37 +- aimux-core/src/lib.rs | 2 +- aimux-core/src/model_catalogue.rs | 5 +- aimux-core/src/options.rs | 6 - aimux-core/src/provider.rs | 144 ++++-- aimux-core/src/recording.rs | 191 +++++--- aimux-core/src/replay.rs | 43 +- aimux-core/src/reranking_model.rs | 5 - aimux-core/src/search_model.rs | 5 - aimux-core/src/speech_model.rs | 5 - aimux-core/src/trace/layer.rs | 32 +- aimux-core/src/transcription_model.rs | 5 - aimux-core/src/video_model.rs | 5 - aimux-core/tests/provider_trait_test.rs | 151 ++++++ aimux-ffi/aimux-ffi.h | 7 +- aimux-ffi/src/lib.rs | 73 +-- aimux-ffi/tests/exports_smoke_test.rs | 29 +- aimux-providers/src/anthropic/convert.rs | 6 - aimux-providers/src/anthropic/mod.rs | 30 +- aimux-providers/src/anthropic/model.rs | 71 +-- aimux-providers/src/anthropic_aws/mod.rs | 13 +- aimux-providers/src/anthropic_aws/model.rs | 27 -- aimux-providers/src/assemblyai.rs | 2 +- aimux-providers/src/aws_polly.rs | 7 +- aimux-providers/src/azure/model.rs | 62 +-- aimux-providers/src/azure/responses.rs | 47 -- aimux-providers/src/bedrock/mod.rs | 27 +- aimux-providers/src/bedrock/model.rs | 45 +- aimux-providers/src/bedrock_mantle.rs | 14 +- aimux-providers/src/black_forest_labs.rs | 2 +- aimux-providers/src/catalogue.rs | 2 +- aimux-providers/src/codex.rs | 57 +-- aimux-providers/src/cohere/mod.rs | 27 +- aimux-providers/src/cohere/model.rs | 34 -- aimux-providers/src/cybertron.rs | 14 +- aimux-providers/src/dataforseo.rs | 7 +- aimux-providers/src/docker_model_runner.rs | 15 +- aimux-providers/src/exa_ai.rs | 7 +- aimux-providers/src/fal.rs | 4 +- aimux-providers/src/firecrawl.rs | 7 +- aimux-providers/src/gaudi.rs | 14 +- aimux-providers/src/gladia.rs | 2 +- aimux-providers/src/google/mod.rs | 34 +- aimux-providers/src/google/model.rs | 39 -- aimux-providers/src/google_pse.rs | 7 +- aimux-providers/src/huggingface.rs | 19 +- aimux-providers/src/huggingface/responses.rs | 9 - aimux-providers/src/jina_ai.rs | 8 +- aimux-providers/src/jlama.rs | 14 +- aimux-providers/src/lib.rs | 176 ++++++- aimux-providers/src/linkup.rs | 7 +- aimux-providers/src/litellm_proxy.rs | 14 +- aimux-providers/src/llamacpp.rs | 14 +- aimux-providers/src/llamafile.rs | 14 +- aimux-providers/src/lmstudio.rs | 14 +- aimux-providers/src/local.rs | 14 +- aimux-providers/src/localai.rs | 14 +- aimux-providers/src/luma.rs | 2 +- aimux-providers/src/mistral/mod.rs | 19 +- aimux-providers/src/mistral/model.rs | 37 -- aimux-providers/src/mistralrs.rs | 14 +- aimux-providers/src/mlx.rs | 14 +- aimux-providers/src/ollama.rs | 14 +- aimux-providers/src/omlx.rs | 14 +- aimux-providers/src/onnx.rs | 14 +- aimux-providers/src/oobabooba.rs | 14 +- aimux-providers/src/open_responses.rs | 72 +-- aimux-providers/src/openai/convert.rs | 25 +- aimux-providers/src/openai/mod.rs | 103 ++-- aimux-providers/src/openai/model.rs | 48 +- aimux-providers/src/openai/responses/mod.rs | 4 - aimux-providers/src/openrouter.rs | 14 +- aimux-providers/src/openvino.rs | 14 +- aimux-providers/src/parallel_ai.rs | 7 +- aimux-providers/src/provider.rs | 170 +++++-- aimux-providers/src/recraft.rs | 9 +- aimux-providers/src/replay.rs | 459 ++++-------------- aimux-providers/src/replicate.rs | 2 +- aimux-providers/src/revai.rs | 2 +- aimux-providers/src/runwayml.rs | 7 +- aimux-providers/src/searxng.rs | 7 +- aimux-providers/src/serper.rs | 7 +- aimux-providers/src/sglang.rs | 14 +- aimux-providers/src/stability.rs | 7 +- aimux-providers/src/tavily.rs | 7 +- aimux-providers/src/tinyfish.rs | 7 +- aimux-providers/src/vertex/anthropic_model.rs | 45 +- aimux-providers/src/vertex/mod.rs | 38 +- aimux-providers/src/vertex/model.rs | 44 +- aimux-providers/src/vertex_ai_ai21_models.rs | 15 +- .../src/vertex_ai_anthropic_models.rs | 15 +- .../src/vertex_ai_deepseek_models.rs | 15 +- aimux-providers/src/vertex_ai_llama_models.rs | 15 +- .../src/vertex_ai_minimax_models.rs | 15 +- .../src/vertex_ai_mistral_models.rs | 15 +- .../src/vertex_ai_moonshot_models.rs | 15 +- .../src/vertex_ai_openai_models.rs | 15 +- aimux-providers/src/vertex_ai_qwen_models.rs | 15 +- aimux-providers/src/vertex_ai_zai_models.rs | 15 +- aimux-providers/src/vllm.rs | 14 +- aimux-providers/src/voyage/mod.rs | 24 +- aimux-providers/src/xai/mod.rs | 18 +- aimux-providers/src/xai/model.rs | 11 - aimux-providers/src/xai/responses/mod.rs | 9 - aimux-providers/src/xinference.rs | 14 +- aimux-providers/src/you_com.rs | 7 +- aimux-providers/tests/alibaba_test.rs | 3 +- aimux-providers/tests/anthropic_files_test.rs | 8 - aimux-providers/tests/baseten_test.rs | 3 +- .../tests/bedrock_remaining_test.rs | 7 +- aimux-providers/tests/body_overrides_test.rs | 129 +++-- aimux-providers/tests/cerebras_test.rs | 3 +- aimux-providers/tests/config_snapshot_test.rs | 208 -------- aimux-providers/tests/conformance_test.rs | 21 +- aimux-providers/tests/convert_golden_test.rs | 7 +- aimux-providers/tests/dataforseo_test.rs | 19 +- aimux-providers/tests/deepinfra_test.rs | 3 +- aimux-providers/tests/deepseek_chat_test.rs | 3 +- .../tests/deepseek_reasoning_test.rs | 3 +- aimux-providers/tests/exa_ai_test.rs | 4 +- aimux-providers/tests/firecrawl_test.rs | 4 +- aimux-providers/tests/fireworks_test.rs | 3 +- aimux-providers/tests/google_files_test.rs | 3 +- aimux-providers/tests/google_model_test.rs | 1 - aimux-providers/tests/google_pse_test.rs | 19 +- .../tests/google_remaining_test.rs | 9 +- aimux-providers/tests/groq_test.rs | 3 +- aimux-providers/tests/jina_ai_test.rs | 19 +- aimux-providers/tests/linkup_test.rs | 19 +- aimux-providers/tests/list_models_test.rs | 41 +- aimux-providers/tests/moonshotai_test.rs | 3 +- aimux-providers/tests/open_responses_test.rs | 86 ---- .../tests/openai_compatible_test.rs | 13 +- .../tests/openai_convert_extended_test.rs | 1 - aimux-providers/tests/openai_files_test.rs | 3 +- .../tests/openai_model_extended_test.rs | 1 - aimux-providers/tests/openai_model_test.rs | 1 - aimux-providers/tests/openai_provider_test.rs | 4 +- aimux-providers/tests/openrouter_test.rs | 9 +- aimux-providers/tests/parallel_ai_test.rs | 19 +- aimux-providers/tests/perplexity_test.rs | 3 +- aimux-providers/tests/provider_trait_test.rs | 115 +++++ aimux-providers/tests/reasoning_map_test.rs | 31 +- aimux-providers/tests/recraft_test.rs | 10 +- aimux-providers/tests/searxng_test.rs | 19 +- aimux-providers/tests/serper_test.rs | 4 +- aimux-providers/tests/tavily_test.rs | 4 +- .../tests/thin_wrapper_config_test.rs | 13 +- aimux-providers/tests/tinyfish_test.rs | 19 +- .../tests/vertex_remaining_test.rs | 9 +- aimux-providers/tests/you_com_test.rs | 19 +- bindings/node/__test__/body_overrides.test.ts | 254 ---------- bindings/node/__test__/error.test.ts | 13 +- .../node/__test__/provider_config.test.ts | 154 ++++++ bindings/node/index.d.ts | 12 +- bindings/node/src/lib.rs | 52 +- bindings/node/src/types/CallOptions.ts | 6 - .../node/src/types/GenerateTextOptions.ts | 4 - bindings/node/src/types/InputRecord.ts | 2 +- bindings/node/src/types/ModelSpec.ts | 3 +- bindings/node/src/types/ProviderRecord.ts | 28 +- bindings/node/src/types/Recording.ts | 3 +- bindings/python/python/aimux/__init__.py | 6 +- bindings/python/python/aimux/wrapper.py | 1 - bindings/python/src/lib.rs | 17 +- bindings/python/tests/test_aimux.py | 16 +- contract-tests/fixtures/wire-format.json | 8 +- scripts/check_provider_boundaries.sh | 83 ++++ tools/aimux-cli/src/probe/provider.rs | 2 +- tools/aimux-replay/src/main.rs | 30 +- tools/aimux-web/examples/gen_demo_fixtures.rs | 9 +- tools/aimux-web/examples/gen_fixture.rs | 9 +- tools/aimux-web/src/api/replay.rs | 42 +- tools/aimux-web/src/api/traces.rs | 10 +- tools/aimux-web/src/model_builder.rs | 2 +- tools/aimux-web/src/wire.rs | 3 - .../web/src/types/generated/WireOptions.ts | 2 +- tools/aimux-web/web/src/views/Playground.vue | 6 - 185 files changed, 2083 insertions(+), 2853 deletions(-) create mode 100644 aimux-core/tests/provider_trait_test.rs delete mode 100644 aimux-providers/tests/config_snapshot_test.rs create mode 100644 aimux-providers/tests/provider_trait_test.rs delete mode 100644 bindings/node/__test__/body_overrides.test.ts create mode 100644 bindings/node/__test__/provider_config.test.ts create mode 100755 scripts/check_provider_boundaries.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398d4339e..a4ecd9bbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -398,6 +398,8 @@ jobs: - uses: actions/checkout@v4 - name: Providers doc generator drift (docs/api/providers.md must match generator output) run: python3 scripts/gen_providers_doc.py --check + - name: Provider boundaries (no call-level reads in providers, removed names stay removed) + run: bash scripts/check_provider_boundaries.sh - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: actions/setup-node@v4 diff --git a/aimux-core/Cargo.toml b/aimux-core/Cargo.toml index e41db186f..ed44ea5ba 100644 --- a/aimux-core/Cargo.toml +++ b/aimux-core/Cargo.toml @@ -28,6 +28,7 @@ httpdate = { workspace = true } rand = { workspace = true } base64 = "0.22" jsonschema = { workspace = true } +regex = "1" [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "test-util"] } diff --git a/aimux-core/src/embedding_model.rs b/aimux-core/src/embedding_model.rs index c6ba1011a..e97ff1dab 100644 --- a/aimux-core/src/embedding_model.rs +++ b/aimux-core/src/embedding_model.rs @@ -117,11 +117,6 @@ pub struct EmbeddingResponse { /// `true`; otherwise they should return an error. #[async_trait] pub trait EmbeddingModel: Send + Sync { - /// Specification version (always `"v4"`). - fn specification_version(&self) -> &'static str { - "v4" - } - /// Provider name, e.g. `"openai"`. fn provider(&self) -> &str; diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index b8d7db23f..090e3089a 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -315,6 +315,18 @@ pub fn is_retryable_status(status: u16) -> bool { } impl AiMuxError { + /// Build a [`NoSuchModel`](Self::NoSuchModel) for `model_id`. + /// + /// `model_type` is the AI SDK's `modelType` (`"languageModel"`, + /// `"embeddingModel"`, `"imageModel"`, …). + #[must_use] + pub fn no_such_model(model_id: &str, model_type: &str) -> Self { + Self::NoSuchModel { + model_id: model_id.to_string(), + model_type: model_type.to_string(), + } + } + /// Returns `true` for a malformed individual stream frame that does not /// prove the underlying transport has failed. Stream reducers may report /// these errors and continue polling for later, independently framed data. diff --git a/aimux-core/src/files_model.rs b/aimux-core/src/files_model.rs index d2fcb1120..091488838 100644 --- a/aimux-core/src/files_model.rs +++ b/aimux-core/src/files_model.rs @@ -98,11 +98,6 @@ pub struct UploadFileResult { /// et al. #[async_trait] pub trait Files: Send + Sync { - /// Specification version (always `"v4"`). - fn specification_version(&self) -> &'static str { - "v4" - } - /// Provider ID. fn provider(&self) -> &str; diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 627796646..9e9f00348 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -72,8 +72,6 @@ pub struct GenerateTextOptions { pub reasoning: Option, /// System instructions prepended to the prompt. pub instructions: Option, - /// Per-call request body overrides (deep-merged). See RFC-0017. - pub body_overrides: Option, /// Per-call retry count override. `None` = provider default, `Some(0)` = disable. pub max_retries: Option, /// Per-call timeout configuration (total / first-chunk / chunk idle). @@ -123,7 +121,6 @@ impl GenerateTextOptions { headers: self.headers, provider_options: self.provider_options, reasoning: self.reasoning, - body_overrides: self.body_overrides, max_retries: self.max_retries, timeout: self.timeout, session_id: self.session_id, @@ -579,8 +576,10 @@ pub async fn generate_text( model.provider(), model.model_id(), ); - ctx.recorder - .record_provider(&ctx.call_id, &model.config_snapshot()); + ctx.recorder.record_provider( + &ctx.call_id, + &crate::recording::ProviderRecord::from_model(model.provider(), model.model_id()), + ); // 层 A 早发 closed(defense-in-depth):无 HTTP 的调用(mock/local)也 // 能完成 barrier;真实 HTTP 的骨架 finalized=false 仍会挡写,由层 B // 结束再发 closed 完成。双发幂等。 @@ -927,8 +926,10 @@ pub async fn stream_text( model.provider(), model.model_id(), ); - ctx.recorder - .record_provider(&ctx.call_id, &model.config_snapshot()); + ctx.recorder.record_provider( + &ctx.call_id, + &crate::recording::ProviderRecord::from_model(model.provider(), model.model_id()), + ); // 层 A 早发 closed(defense-in-depth):无 HTTP 的调用(mock/local)也 // 能完成 barrier;真实 HTTP 的骨架 finalized=false 仍会挡写,由层 B // 结束再发 closed 完成。双发幂等。 diff --git a/aimux-core/src/image_model.rs b/aimux-core/src/image_model.rs index 1a04fbe26..590af6b8a 100644 --- a/aimux-core/src/image_model.rs +++ b/aimux-core/src/image_model.rs @@ -181,11 +181,6 @@ pub struct ImageResponse { /// Aligned with V4 `ImageModelV4`. #[async_trait] pub trait ImageModel: Send + Sync { - /// Specification version (always `"v4"`). - fn specification_version(&self) -> &'static str { - "v4" - } - /// Provider name, e.g. `"openai"`. fn provider(&self) -> &str; diff --git a/aimux-core/src/language_model.rs b/aimux-core/src/language_model.rs index 55315e84c..8cdcbbcbe 100644 --- a/aimux-core/src/language_model.rs +++ b/aimux-core/src/language_model.rs @@ -3,12 +3,31 @@ //! Every LLM provider implements this trait. Users never call `do_generate` / //! `do_stream` directly; they call `generate_text` / `stream_text` instead. +use std::collections::HashMap; + use async_trait::async_trait; +use regex::Regex; use crate::error::AiMuxError; use crate::options::CallOptions; use crate::result::{GenerateResult, StreamResult}; +/// URL patterns a language model can fetch itself, keyed by media-type +/// pattern (`"image/*"`, `"application/pdf"`, …). Each value is a list of +/// regular expressions matched against the full URL. +/// +/// The empty default means the model fetches nothing itself. +#[derive(Debug, Clone, Default)] +pub struct SupportedUrls(pub HashMap>); + +impl SupportedUrls { + /// Whether no media type has a supported URL pattern. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.values().all(Vec::is_empty) + } +} + /// The unified language model trait (provider-facing). /// /// Aligned with Vercel AI SDK `LanguageModelV4`. @@ -23,11 +42,6 @@ use crate::result::{GenerateResult, StreamResult}; /// rather than being silently dropped. #[async_trait] pub trait LanguageModel: Send + Sync { - /// Specification version (always `"v4"`). - fn specification_version(&self) -> &'static str { - "v4" - } - /// Provider name, e.g. `"openai"`. fn provider(&self) -> &str; @@ -40,10 +54,13 @@ pub trait LanguageModel: Send + Sync { /// Generate a streaming response. async fn do_stream(&self, options: &CallOptions) -> Result; - /// RFC-0023: 配置侧快照供录制。默认返回最小信息(provider/model_id); - /// 各 provider 覆盖以提供 base_url/profile/api_key 来源等完整配置。 - /// 透明 decorator(TraceLayer 等)必须覆盖并转发 inner 快照。 - fn config_snapshot(&self) -> crate::recording::ProviderRecord { - crate::recording::ProviderRecord::minimal(self.provider(), self.model_id()) + /// URLs the model can fetch on its own, keyed by media-type pattern + /// (e.g. `"image/*"`), AI SDK `supportedUrls`. + /// + /// Default: empty, meaning every file part is sent as inline data. + /// Transparent decorators must forward the inner model's value. Core does + /// not consult this yet. + fn supported_urls(&self) -> SupportedUrls { + SupportedUrls::default() } } diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index 9dc6e9278..9184bd5e1 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -73,7 +73,7 @@ pub mod prelude { generate_text_result_to_chat_completion, stream_text, stream_text_as_openai, }; pub use crate::image_model::{ImageCallOptions, ImageModel, ImageResult, generate_image}; - pub use crate::language_model::LanguageModel; + pub use crate::language_model::{LanguageModel, SupportedUrls}; pub use crate::language_model_message::LanguageModelPrompt; pub use crate::message::{MessageContent, ModelMessage, ModelPrompt, Role}; pub use crate::moa::{MoaConfig, MoaFailMode, MoaModel}; diff --git a/aimux-core/src/model_catalogue.rs b/aimux-core/src/model_catalogue.rs index fc045ed32..a7ef0d8e9 100644 --- a/aimux-core/src/model_catalogue.rs +++ b/aimux-core/src/model_catalogue.rs @@ -2,7 +2,7 @@ //! //! Two independent data surfaces: //! - [`RuntimeModel`] — what a provider's `/models` endpoint returns (sparse: -//! id, owned_by, created). Produced by `Provider::list_models`. +//! id, owned_by, created). Produced by `ProviderDiscovery::list_models`. //! - [`ModelSpec`] — community knowledge portrait (context length, capabilities, //! reasoning). Produced by `get_model_specs` (anya2a). //! @@ -219,8 +219,7 @@ pub struct ModelCost { /// The full static portrait for a model, supplemented from community knowledge. /// /// All fields are advisory — aimux never auto-applies them in the request path. -/// The caller reads them to decide how to configure `GenerateTextOptions` / -/// `bodyOverrides`. +/// The caller reads them to decide how to configure `GenerateTextOptions`. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS, PartialEq)] #[ts(export)] pub struct ModelSpec { diff --git a/aimux-core/src/options.rs b/aimux-core/src/options.rs index 3d2891396..ecdbcd694 100644 --- a/aimux-core/src/options.rs +++ b/aimux-core/src/options.rs @@ -114,11 +114,6 @@ pub struct CallOptions { /// Anthropic `thinking` config. pub reasoning: Option, - /// Per-call request body overrides. Deep-merged into the provider-built - /// request body (after any built-in vendor override) before sending. - /// `null` values delete the corresponding key. See RFC-0017. - pub body_overrides: Option, - /// Per-call retry count override. `None` uses the provider's configured /// Core operation retry. `Some(0)` disables retries. pub max_retries: Option, @@ -207,7 +202,6 @@ impl CallOptions { headers: None, provider_options: None, reasoning: None, - body_overrides: None, max_retries: None, timeout: None, session_id: None, diff --git a/aimux-core/src/provider.rs b/aimux-core/src/provider.rs index 893d9affb..43ed3ecb9 100644 --- a/aimux-core/src/provider.rs +++ b/aimux-core/src/provider.rs @@ -1,59 +1,131 @@ -//! The `Provider` trait — a factory that creates `LanguageModel` instances. +//! The `Provider` trait — a factory that creates model instances for every +//! modality a vendor offers. +//! +//! Mirrors the AI SDK's `ProviderV4`: three required model constructors +//! (language / embedding / image), four optional ones that default to "not +//! offered", and a `files()` accessor. Runtime model discovery lives on the +//! separate [`ProviderDiscovery`] trait. -use std::future::Future; -use std::pin::Pin; +use std::sync::Arc; +use futures::future::BoxFuture; + +use crate::embedding_model::EmbeddingModel; use crate::error::AiMuxError; +use crate::files_model::Files; +use crate::image_model::ImageModel; use crate::language_model::LanguageModel; use crate::model_catalogue::RuntimeModel; +use crate::reranking_model::RerankingModel; +use crate::search_model::SearchModel; +use crate::speech_model::SpeechModel; +use crate::transcription_model::TranscriptionModel; +use crate::video_model::VideoModel; /// A provider factory. /// -/// Holds API keys / config and creates `LanguageModel` instances by model name. +/// Holds API keys / config and creates model instances by model id. The +/// shape follows AI SDK `ProviderV4`: +/// +/// - [`language_model`](Self::language_model), +/// [`embedding_model`](Self::embedding_model) and +/// [`image_model`](Self::image_model) are required. A vendor that does not +/// offer a modality returns [`AiMuxError::NoSuchModel`] carrying the +/// matching `model_type` (`"languageModel"`, `"embeddingModel"`, +/// `"imageModel"`). Credential or settings errors are never reported as +/// `NoSuchModel`. +/// - [`transcription_model`](Self::transcription_model), +/// [`speech_model`](Self::speech_model), +/// [`reranking_model`](Self::reranking_model) and [`files`](Self::files) +/// are optional: `None` means the vendor does not offer that modality. +/// - [`video_model`](Self::video_model) and +/// [`search_model`](Self::search_model) are aimux extensions; they are not +/// members of `ProviderV4`. +/// +/// A provider has no `name()`: the name it was registered under belongs to +/// whoever holds the registry, and each model reports its own +/// [`LanguageModel::provider`] string. pub trait Provider: Send + Sync { - /// Unique provider name (e.g. `"openai"`). - fn name(&self) -> &str; + /// Create a language model by its id (e.g. `"gpt-4o"`). + /// + /// # Errors + /// + /// Returns [`AiMuxError::NoSuchModel`] with `model_type: "languageModel"` + /// when the vendor offers no language models; implementations may also + /// return lookup/auth errors for ids they cannot serve. + fn language_model(&self, id: &str) -> Result, AiMuxError>; - /// Create a model instance by its name string (e.g. `"gpt-4o"`). + /// Create an embedding model by its id (e.g. `"text-embedding-3-large"`). /// - /// Non-language-model providers (image/video/speech/search/… — e.g. Tavily, - /// Stability, Recraft) do not implement this and get the default - /// `Unsupported` error. Only providers that actually expose a language - /// model override it (issue M9). + /// # Errors + /// + /// Returns [`AiMuxError::NoSuchModel`] with `model_type: "embeddingModel"` + /// when the vendor offers no embedding models. + fn embedding_model(&self, id: &str) -> Result, AiMuxError>; + + /// Create an image model by its id (e.g. `"dall-e-3"`). /// /// # Errors /// - /// The default implementation returns `AiMuxError::UnsupportedFunctionality`; - /// implementations return lookup/auth errors for model ids they cannot serve. - fn language_model(&self, _model_id: &str) -> Result, AiMuxError> { - Err(AiMuxError::UnsupportedFunctionality(format!( - "provider '{}' does not provide language models", - self.name() - ))) + /// Returns [`AiMuxError::NoSuchModel`] with `model_type: "imageModel"` + /// when the vendor offers no image models. + fn image_model(&self, id: &str) -> Result, AiMuxError>; + + /// Create a transcription (STT) model; `None` when not offered. + fn transcription_model( + &self, + id: &str, + ) -> Option, AiMuxError>> { + let _ = id; + None + } + + /// Create a speech (TTS) model; `None` when not offered. + fn speech_model(&self, id: &str) -> Option, AiMuxError>> { + let _ = id; + None + } + + /// Create a reranking model; `None` when not offered. + fn reranking_model(&self, id: &str) -> Option, AiMuxError>> { + let _ = id; + None } + /// The vendor's file-management interface; `None` when not offered. + fn files(&self) -> Option> { + None + } + + /// Create a video-generation model; `None` when not offered. + /// + /// aimux extension — not a member of AI SDK `ProviderV4`. + fn video_model(&self, id: &str) -> Option, AiMuxError>> { + let _ = id; + None + } + + /// Create a web-search model; `None` when not offered. + /// + /// aimux extension — not a member of AI SDK `ProviderV4`. + fn search_model(&self, id: &str) -> Option, AiMuxError>> { + let _ = id; + None + } +} + +/// Runtime model discovery, implemented by providers that expose a +/// model-list endpoint (RFC-0027). +/// +/// Separate from [`Provider`] because `ProviderV4` has no equivalent and most +/// vendors cannot list models. +pub trait ProviderDiscovery: Send + Sync { /// List the models this account can call on this provider, via the - /// provider's `/models` endpoint (runtime discovery). + /// provider's `/models` endpoint. /// /// Returns **only the provider's official data** (`RuntimeModel`: id, /// owned_by, created) — no community catalogue enrichment. To supplement /// with model specs (context length, capabilities, reasoning portrait), /// call `get_model_specs` separately and merge in the host (RFC-0027). - /// - /// Default returns [`AiMuxError::UnsupportedFunctionality`] — providers that expose a - /// model-list endpoint override this. - /// - /// Implemented as a `Pin>` (rather than `#[async_trait]`) so that - /// the dozens of existing `Provider` impls need no changes — only providers - /// that actually support `/models` override this. - fn list_models( - &self, - ) -> Pin, AiMuxError>> + Send + '_>> { - let name = self.name().to_string(); - Box::pin(async move { - Err(AiMuxError::UnsupportedFunctionality(format!( - "list_models not implemented for provider '{name}'" - ))) - }) - } + fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>>; } diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index dcd3965b8..589effaec 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -29,13 +29,31 @@ use ts_rs::TS; // ── 数据模型(三层 + call_id 关联;schema 版本)──────────────────────────── /// 录制格式版本(用于未来字段迁移与绑定层兼容)。 -pub const RECORDING_SCHEMA: u32 = 2; +pub const RECORDING_SCHEMA: u32 = 3; + +/// 只接受当前 [`RECORDING_SCHEMA`]:反序列化旧版本录制时给出明确错误。 +fn deserialize_current_schema<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let schema = u32::deserialize(deserializer)?; + if schema == RECORDING_SCHEMA { + Ok(schema) + } else { + Err(serde::de::Error::custom(format!( + "unsupported recording schema {schema}: only schema {RECORDING_SCHEMA} is \ + readable, recordings written by older versions are no longer read" + ))) + } +} /// 一次完整调用的录制记录(三层 + call_id 关联)。 #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] pub struct Recording { - /// 格式版本。 + /// 格式版本。读取时只接受当前 [`RECORDING_SCHEMA`];旧 schema 的录制带有 + /// 配置快照(base_url / profile 等),不再读取。 + #[serde(deserialize_with = "deserialize_current_schema")] pub schema: u32, /// 暴露逻辑调用 ID,关联三层。 pub call_id: String, @@ -115,7 +133,7 @@ pub struct InputRecord { /// 完整 prompt(消息数组,含 ContentPart::Image 等多模态)。 pub prompt: LanguageModelPrompt, /// 序列化的 CallOptions(abort_signal/call_id 已 serde skip); - /// headers/provider_options/body_overrides 已递归脱敏。 + /// headers/provider_options 已递归脱敏。 pub options: serde_json::Value, } @@ -132,36 +150,39 @@ impl InputRecord { } /// ② 配置侧:provider 身份(请求回放重建用)。 +/// +/// 只记身份,不记配置:回放时按 `provider_id` + `model_id` 重新构建 model, +/// 凭证与 base URL 只来自环境/registry(RFC-0036 §3.3)。 #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] pub struct ProviderRecord { - /// `model.provider()`,如 "openai"。 + /// provider 名(registry 名),回放重建的查找键,如 "openai"、"deepseek"。 + pub provider_id: String, + /// `model.provider()`,如 "openai.chat"。 pub provider: String, /// `model.model_id()`,如 "gpt-4o"。 pub model_id: String, - /// provider 的 base_url。 - pub base_url: Option, - /// api_key 来源(不存明文):"env:OPENAI_API_KEY" / "explicit" / "none" / "unknown"。 - pub api_key_source: String, - /// OpenAICompatProfile(能力差异)。 - pub profile: Option, - /// ProviderOptions(headers/org/project/...;已脱敏)。 - pub provider_options: Option, } impl ProviderRecord { - /// 最小快照(provider/model_id)——`config_snapshot` 默认实现;cover 的部分放结构方法。 + /// 显式给出三项身份。 #[must_use] - pub fn minimal(provider: &str, model_id: &str) -> Self { + pub fn new(provider_id: &str, provider: &str, model_id: &str) -> Self { Self { + provider_id: provider_id.to_string(), provider: provider.to_string(), model_id: model_id.to_string(), - base_url: None, - api_key_source: "unknown".to_string(), - profile: None, - provider_options: None, } } + + /// 由 model 的 `provider()` / `model_id()` 构造:`provider_id` 暂取 + /// `provider()` 字符串的首段(`"openai.chat"` → `"openai"`);registry + /// 名接入后由其填写。 + #[must_use] + pub fn from_model(provider: &str, model_id: &str) -> Self { + let provider_id = provider.split('.').next().unwrap_or_default(); + Self::new(provider_id, provider, model_id) + } } /// ③ HTTP 侧:单次 attempt 的 wire 交换。 @@ -337,7 +358,7 @@ pub trait Recorder: Send + Sync { /// 默认空实现(不参与录制的 Recorder 无需关心);开启录制且调用被归组 /// 时,`generate_text`/`stream_text` 入口调用它。 fn record_session(&self, _call_id: &str, _session_id: &str, _step: u32) {} - /// 录制配置侧完整快照。 + /// 录制配置侧身份(`ProviderRecord`:provider_id / provider / model_id)。 fn record_provider(&self, call_id: &str, snapshot: &ProviderRecord); /// 录制单次 HTTP 交换(层 B http.rs 调用,per-attempt 一条)。 fn record_exchange(&self, call_id: &str, exchange: &HttpExchange); @@ -534,7 +555,7 @@ pub fn is_sensitive_key(name: &str) -> bool { } /// 递归脱敏(JSON 中含敏感键的项值替换为 `[REDACTED]`)。 -/// 覆盖 `CallOptions.headers`/`provider_options`/`body_overrides` 任意层级。 +/// 覆盖 `CallOptions.headers`/`provider_options` 任意层级。 pub(crate) fn redact_json(value: serde_json::Value) -> serde_json::Value { match value { serde_json::Value::Object(map) => { @@ -766,7 +787,7 @@ impl Recorder for JsonlRecorder { self.send_ev(RecordEvent::Input { call_id: call_id.to_string(), input: InputRecord::from_call_options(options), - provider: ProviderRecord::minimal(provider, model_id), + provider: ProviderRecord::from_model(provider, model_id), }); } @@ -779,14 +800,9 @@ impl Recorder for JsonlRecorder { } fn record_provider(&self, call_id: &str, snapshot: &ProviderRecord) { - // 核心边界统一强制脱敏 provider_options/profile(B6 评审), - // 不依赖 provider 端自觉。 - let mut snap = snapshot.clone(); - snap.provider_options = snap.provider_options.take().map(redact_json); - snap.profile = snap.profile.take().map(redact_json); self.send_ev(RecordEvent::Provider { call_id: call_id.to_string(), - provider: snap, + provider: snapshot.clone(), }); } @@ -850,7 +866,7 @@ fn entry_or_init<'a>( prompt: Vec::new(), options: serde_json::Value::Null, }, - ProviderRecord::minimal("", ""), + ProviderRecord::from_model("", ""), ) }) } @@ -1321,7 +1337,7 @@ impl RingInner { prompt: Vec::new(), options: serde_json::Value::Null, }, - ProviderRecord::minimal("", ""), + ProviderRecord::from_model("", ""), ), ); } @@ -1395,7 +1411,7 @@ impl Recorder for RingRecorder { let rec = inner.entry_or_init_bounded(call_id); rec.input = InputRecord::from_call_options(options); if rec.provider.provider.is_empty() { - rec.provider = ProviderRecord::minimal(provider, model_id); + rec.provider = ProviderRecord::from_model(provider, model_id); } } @@ -1409,13 +1425,9 @@ impl Recorder for RingRecorder { } fn record_provider(&self, call_id: &str, snapshot: &ProviderRecord) { - // 与 JsonlRecorder 一致的边界强制脱敏,不依赖 provider 端自觉。 - let mut snap = snapshot.clone(); - snap.provider_options = snap.provider_options.take().map(redact_json); - snap.profile = snap.profile.take().map(redact_json); let mut inner = self.inner.lock().unwrap(); // C4-6:entry_or_init_bounded 兜底建条目,保证乱序 Provider 快照不丢。 - inner.entry_or_init_bounded(call_id).provider = snap; + inner.entry_or_init_bounded(call_id).provider = snapshot.clone(); } fn record_exchange(&self, call_id: &str, exchange: &HttpExchange) { @@ -1853,11 +1865,13 @@ mod tests { "x-goog-api-key": "gkey", "x-amz-security-token": "sts-tok" }, - "provider_options": { "headers": { "Cookie": "s=1" } }, - "body_overrides": { - "api_key": "sk-secret", - "token": "bearer-secret", - "access_token": "oauth-secret" + "provider_options": { + "headers": { "Cookie": "s=1" }, + "openai": { + "api_key": "sk-secret", + "token": "bearer-secret", + "access_token": "oauth-secret" + } }, // 用量字段名含 "token" 子串,但非凭据——contains("token") 曾误伤。 "usage": { @@ -1873,9 +1887,12 @@ mod tests { assert_eq!(r["headers"]["x-amz-security-token"], "[REDACTED]"); assert_eq!(r["headers"]["X-Key"], "ok"); assert_eq!(r["provider_options"]["headers"]["Cookie"], "[REDACTED]"); - assert_eq!(r["body_overrides"]["api_key"], "[REDACTED]"); - assert_eq!(r["body_overrides"]["token"], "[REDACTED]"); - assert_eq!(r["body_overrides"]["access_token"], "[REDACTED]"); + assert_eq!(r["provider_options"]["openai"]["api_key"], "[REDACTED]"); + assert_eq!(r["provider_options"]["openai"]["token"], "[REDACTED]"); + assert_eq!( + r["provider_options"]["openai"]["access_token"], + "[REDACTED]" + ); // 含 "token" 子串的用量字段不再被误脱敏(回归 contains("token"))。 assert_eq!(r["usage"]["max_output_tokens"], 4096); assert_eq!(r["usage"]["prompt_tokens"], 10); @@ -1888,7 +1905,7 @@ mod tests { let rec = Recording::new( "call-1", InputRecord::from_call_options(&sample_options()), - ProviderRecord::minimal("openai", "gpt-4o"), + ProviderRecord::from_model("openai", "gpt-4o"), ); let json = serde_json::to_string(&rec).unwrap(); let back: Recording = serde_json::from_str(&json).unwrap(); @@ -1939,7 +1956,7 @@ mod tests { prompt: Vec::new(), options: serde_json::Value::Null, }, - ProviderRecord::minimal("openai", "gpt-4o"), + ProviderRecord::from_model("openai", "gpt-4o"), ); rec.transport_closed = true; rec.outcome = OutcomeRecord { @@ -2012,7 +2029,7 @@ mod tests { tx.send(RecordEvent::Input { call_id: "c1".into(), input: InputRecord::from_call_options(&sample_options()), - provider: ProviderRecord::minimal("openai", "gpt-4o"), + provider: ProviderRecord::from_model("openai", "gpt-4o"), }) .unwrap(); tx.send(RecordEvent::TransportClosed { @@ -2086,7 +2103,7 @@ mod tests { let rec = Recording::new( "call-1", InputRecord::from_call_options(&sample_options()), - ProviderRecord::minimal("openai", "gpt-4o"), + ProviderRecord::from_model("openai", "gpt-4o"), ); let json = serde_json::to_string(&rec).unwrap(); assert!(!json.contains("session_id"), "ungrouped must omit: {json}"); @@ -2621,17 +2638,65 @@ mod tests { assert_eq!(parsed.outcome.status, OutcomeStatus::Incomplete); } + // ── schema 3:只记身份,拒绝旧 schema ─────────────────────────────────── + + #[test] + fn provider_record_from_model_takes_first_segment_as_id() { + let r = ProviderRecord::from_model("openai.chat", "gpt-4o"); + assert_eq!(r.provider_id, "openai"); + assert_eq!(r.provider, "openai.chat"); + assert_eq!(r.model_id, "gpt-4o"); + assert_eq!(ProviderRecord::from_model("groq", "m").provider_id, "groq"); + assert_eq!(ProviderRecord::from_model("", "").provider_id, ""); + } + + #[test] + fn provider_record_serializes_identity_only() { + let v = + serde_json::to_value(ProviderRecord::new("deepseek", "deepseek.chat", "m")).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "provider_id": "deepseek", + "provider": "deepseek.chat", + "model_id": "m", + }) + ); + } + + #[test] + fn current_schema_is_three_and_round_trips() { + assert_eq!(RECORDING_SCHEMA, 3); + let rec = Recording::new( + "c", + InputRecord::from_call_options(&sample_options()), + ProviderRecord::from_model("openai.chat", "gpt-4o"), + ); + let back: Recording = serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap(); + assert_eq!(back.schema, 3); + assert_eq!(back.provider.provider_id, "openai"); + } + + #[test] + fn schema_two_recording_is_refused() { + let rec = Recording::new( + "c", + InputRecord::from_call_options(&sample_options()), + ProviderRecord::from_model("openai.chat", "gpt-4o"), + ); + let mut v = serde_json::to_value(&rec).unwrap(); + v["schema"] = serde_json::json!(2); + let err = serde_json::from_value::(v).unwrap_err(); + assert!(err.is_data(), "wire classification must be Data: {err}"); + let msg = err.to_string(); + assert!(msg.contains("unsupported recording schema 2"), "{msg}"); + assert!(msg.contains("no longer read"), "{msg}"); + } + // ── C4-6:Provider 先于 Input 不丢 ───────────────────────────────────── fn full_provider() -> ProviderRecord { - ProviderRecord { - provider: "openai".into(), - model_id: "gpt-4o".into(), - base_url: Some("https://api.openai.com".into()), - api_key_source: "env:OPENAI_API_KEY".into(), - profile: None, - provider_options: None, - } + ProviderRecord::new("openai", "openai.chat", "gpt-4o") } #[test] @@ -2646,12 +2711,11 @@ mod tests { ring.export_jsonl(&mut buf).unwrap(); let parsed: Recording = serde_json::from_str(std::str::from_utf8(&buf).unwrap().trim()).unwrap(); - assert_eq!(parsed.provider.provider, "openai"); assert_eq!( - parsed.provider.base_url.as_deref(), - Some("https://api.openai.com"), - "full snapshot preserved, not overwritten by minimal" + parsed.provider.provider, "openai.chat", + "explicit record preserved, not overwritten by the input-derived one" ); + assert_eq!(parsed.provider.provider_id, "openai"); assert_eq!(parsed.provider.model_id, "gpt-4o"); } @@ -2667,11 +2731,8 @@ mod tests { rec.flush(); let content = std::fs::read_to_string(rec.path()).unwrap(); let parsed: Recording = serde_json::from_str(content.trim()).unwrap(); - assert_eq!( - parsed.provider.base_url.as_deref(), - Some("https://api.openai.com") - ); - assert_eq!(parsed.provider.provider, "openai"); + assert_eq!(parsed.provider.provider_id, "openai"); + assert_eq!(parsed.provider.provider, "openai.chat"); let _ = std::fs::remove_dir_all(&dir); } @@ -2715,7 +2776,7 @@ mod tests { prompt: Vec::new(), options: serde_json::Value::Null, }, - ProviderRecord::minimal("", ""), + ProviderRecord::from_model("", ""), ) } diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index 79c599fe0..daef135a7 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -45,10 +45,10 @@ pub trait ReplayMatcher: Send + Sync { /// 精确匹配:provider/model_id 相同,且 prompt + 影响响应的可重放选项 /// (temperature/max_output_tokens/seed/response_format/tools/tool_choice) -/// 以及 headers/provider_options/body_overrides 规范相同才命中。运行时字段 +/// 以及 headers/provider_options 规范相同才命中。运行时字段 /// (call_id/abort_signal/recording_context)不参与比较。 /// -/// headers/provider_options/body_overrides 用**脱敏感知比较**:录制侧这些 +/// headers/provider_options 用**脱敏感知比较**:录制侧这些 /// 字段经 `recording::redact_json` 脱敏 /// (敏感键值→`"[REDACTED]"`),脱敏值视为通配,匹配任意显式请求值;非脱敏 /// 部分仍精确比较。规则见 `redaction_aware_eq`。 @@ -89,9 +89,9 @@ impl ReplayMatcher for ExactMatcher { /// `CallOptions` 中影响响应、可重放选项的规范键(ExactMatcher 用)。 /// /// 含 prompt + temperature/max_output_tokens/seed/response_format/tools/ -/// tool_choice + headers/provider_options/body_overrides;排除运行时字段 +/// tool_choice + headers/provider_options;排除运行时字段 /// (call_id/abort_signal/recording_context)。序列化为 `Value` 后比较, -/// 等价于"稳定规范哈希"但无碰撞风险。headers/provider_options/body_overrides +/// 等价于"稳定规范哈希"但无碰撞风险。headers/provider_options /// 在比较时走脱敏感知语义(见 [`canonical_keys_match`])。 fn canonical_call_key(opts: &CallOptions) -> serde_json::Value { serde_json::json!({ @@ -104,7 +104,6 @@ fn canonical_call_key(opts: &CallOptions) -> serde_json::Value { "tool_choice": serde_json::to_value(&opts.tool_choice).unwrap_or_default(), "headers": serde_json::to_value(&opts.headers).unwrap_or_default(), "provider_options": serde_json::to_value(&opts.provider_options).unwrap_or_default(), - "body_overrides": serde_json::to_value(&opts.body_overrides).unwrap_or_default(), }) } @@ -125,11 +124,10 @@ fn canonical_recording_key(rec: &Recording) -> serde_json::Value { .unwrap_or_else(|| serde_json::to_value(ToolChoice::default()).unwrap_or_default()), "headers": o.get("headers").cloned().unwrap_or_default(), "provider_options": o.get("provider_options").cloned().unwrap_or_default(), - "body_overrides": o.get("body_overrides").cloned().unwrap_or_default(), }) } -/// 脱敏感知比较(用于 headers/provider_options/body_overrides)。 +/// 脱敏感知比较(用于 headers/provider_options)。 /// /// 录制侧这些字段经 `recording::redact_json` /// 脱敏:敏感键(authorization/api-key/apikey/key/token/cookie/...)的**值** @@ -171,7 +169,7 @@ fn redaction_aware_eq(rec: &serde_json::Value, req: &serde_json::Value) -> bool } } -/// 比较规范键:headers/provider_options/body_overrides 用脱敏感知比较 +/// 比较规范键:headers/provider_options 用脱敏感知比较 /// (录制侧可能已脱敏);其余字段(prompt/temperature/...)精确比较。 /// /// 注意:`max_output_tokens` 等含 "token" 子串的字段在录制侧也会被 @@ -179,7 +177,7 @@ fn redaction_aware_eq(rec: &serde_json::Value, req: &serde_json::Value) -> bool /// 被脱敏为 `"[REDACTED]"` 而请求值为数值,精确比较会 miss(保守:宁可 miss /// 不可误 hit,因为 max_output_tokens 影响响应,通配会引入伪命中)。 fn canonical_keys_match(rec_key: &serde_json::Value, call_key: &serde_json::Value) -> bool { - const REDACTED_FIELDS: [&str; 3] = ["headers", "provider_options", "body_overrides"]; + const REDACTED_FIELDS: [&str; 2] = ["headers", "provider_options"]; let (ro, co) = match (rec_key.as_object(), call_key.as_object()) { (Some(a), Some(b)) => (a, b), _ => return rec_key == call_key, @@ -400,8 +398,9 @@ impl MockReplayModel { /// /// # Errors /// - /// Returns `AiMuxError::InvalidArgument` when the file cannot be read or - /// contains no recordings, and `JsonParse` when a line is not valid JSON. + /// Returns `AiMuxError::InvalidArgument` when the file cannot be read, + /// contains no recordings, or holds a recording of an unsupported schema, + /// and `JsonParse` when a line is not valid JSON. pub fn from_jsonl(path: &str) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| AiMuxError::InvalidArgument(format!("mock replay: {e}")))?; @@ -410,8 +409,16 @@ impl MockReplayModel { if line.trim().is_empty() { continue; } - let rec: Recording = serde_json::from_str(line) - .map_err(|e| AiMuxError::JsonParse(format!("mock replay line {}: {e}", idx + 1)))?; + let rec: Recording = serde_json::from_str(line).map_err(|e| { + let msg = format!("mock replay line {}: {e}", idx + 1); + // Well-formed JSON that is not a current-schema `Recording` + // (including an old `schema`) is a bad argument, not a parse error. + if e.classify() == serde_json::error::Category::Data { + AiMuxError::InvalidArgument(msg) + } else { + AiMuxError::JsonParse(msg) + } + })?; recordings.push(rec); } if recordings.is_empty() { @@ -998,7 +1005,6 @@ fn generate_options_from_call_options(o: CallOptions) -> GenerateTextOptions { provider_options: o.provider_options, reasoning: o.reasoning, instructions: None, - body_overrides: o.body_overrides, max_retries: o.max_retries, timeout: o.timeout, session_id: o.session_id, @@ -1065,14 +1071,7 @@ mod tests { prompt: input_prompt, options: serde_json::json!({ "temperature": 0.7 }), }, - provider: ProviderRecord { - provider: "openai".into(), - model_id: "gpt-4o".into(), - base_url: None, - api_key_source: "none".into(), - profile: None, - provider_options: None, - }, + provider: ProviderRecord::new("openai", "openai", "gpt-4o"), exchanges: vec![HttpExchange { step: None, attempt: 0, diff --git a/aimux-core/src/reranking_model.rs b/aimux-core/src/reranking_model.rs index 6e271057f..cf677ebc6 100644 --- a/aimux-core/src/reranking_model.rs +++ b/aimux-core/src/reranking_model.rs @@ -124,11 +124,6 @@ pub struct RerankingResponse { /// Aligned with V4 `RerankingModelV4`. #[async_trait] pub trait RerankingModel: Send + Sync { - /// Specification version (always `"v4"`). - fn specification_version(&self) -> &'static str { - "v4" - } - /// Provider name, e.g. `"cohere"`. fn provider(&self) -> &str; diff --git a/aimux-core/src/search_model.rs b/aimux-core/src/search_model.rs index 32a303f47..4c94c65dd 100644 --- a/aimux-core/src/search_model.rs +++ b/aimux-core/src/search_model.rs @@ -138,11 +138,6 @@ pub struct SearchResponse { /// implement `do_search`; users never call it directly. #[async_trait] pub trait SearchModel: Send + Sync { - /// Specification version (always `"v4"`). - fn specification_version(&self) -> &'static str { - "v4" - } - /// Provider name, e.g. `"tavily"`. fn provider(&self) -> &str; diff --git a/aimux-core/src/speech_model.rs b/aimux-core/src/speech_model.rs index 9d96b1f6f..d83d38c8f 100644 --- a/aimux-core/src/speech_model.rs +++ b/aimux-core/src/speech_model.rs @@ -137,11 +137,6 @@ pub struct SpeechResponse { /// Aligned with V4 `SpeechModelV4`. #[async_trait] pub trait SpeechModel: Send + Sync { - /// Specification version (always `"v4"`). - fn specification_version(&self) -> &'static str { - "v4" - } - /// Provider name, e.g. `"openai"`. fn provider(&self) -> &str; diff --git a/aimux-core/src/trace/layer.rs b/aimux-core/src/trace/layer.rs index 5e2cac10c..8717495cd 100644 --- a/aimux-core/src/trace/layer.rs +++ b/aimux-core/src/trace/layer.rs @@ -163,24 +163,18 @@ impl TraceLayer { .session_id .clone() .or_else(|| self.default_session.clone()); - // B1: scope_key isolates by (provider, base_url, model_id) — not just - // model_id — so two TraceLayers over the same model_id but different - // providers (or base_urls) sharing one RingTraceStore never cross-match - // their LCP history. `base_url` comes from the inner model's config - // snapshot; providers that only report a minimal snapshot omit it, in - // which case provider + model_id still isolate the scope. The default - // `scope_salt` stays process-level (set in `new` / `with_default_session`). + // B1: scope_key isolates by (provider, model_id), so two TraceLayers + // over the same model_id but different providers sharing one + // RingTraceStore never cross-match their LCP history. A model's + // `provider()` string already carries the settings-level name, so two + // endpoints of one vendor stay apart when they are given different + // provider names. The default `scope_salt` stays process-level (set in + // `new` / `with_default_session`). let provider = self.inner.provider(); let model_id = self.inner.model_id(); - let base_url = self.inner.config_snapshot().base_url; - let mut scope_bytes = Vec::with_capacity( - provider.len() + model_id.len() + base_url.as_deref().map_or(0, str::len), - ); + let mut scope_bytes = Vec::with_capacity(provider.len() + model_id.len()); scope_bytes.extend_from_slice(provider.as_bytes()); scope_bytes.extend_from_slice(model_id.as_bytes()); - if let Some(b) = &base_url { - scope_bytes.extend_from_slice(b.as_bytes()); - } let scope_key = hash::hash64(self.scope_salt, &scope_bytes); let sent_at_unix_ms = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -505,10 +499,6 @@ impl Drop for TraceRecordingStream { #[async_trait] impl LanguageModel for TraceLayer { - fn specification_version(&self) -> &'static str { - "v4" - } - fn provider(&self) -> &str { self.inner.provider() } @@ -517,10 +507,8 @@ impl LanguageModel for TraceLayer { self.inner.model_id() } - /// RFC-0023 §3.3: transparent decorators must forward the inner snapshot - /// (otherwise recording sees the decorator's minimal record). - fn config_snapshot(&self) -> crate::recording::ProviderRecord { - self.inner.config_snapshot() + fn supported_urls(&self) -> crate::language_model::SupportedUrls { + self.inner.supported_urls() } async fn do_generate(&self, options: &CallOptions) -> Result { diff --git a/aimux-core/src/transcription_model.rs b/aimux-core/src/transcription_model.rs index 497dbe31f..516d345cf 100644 --- a/aimux-core/src/transcription_model.rs +++ b/aimux-core/src/transcription_model.rs @@ -284,11 +284,6 @@ impl std::fmt::Debug for TranscriptionStreamResult { /// Aligned with V4 `TranscriptionModelV4`. #[async_trait] pub trait TranscriptionModel: Send + Sync { - /// Specification version (always `"v4"`). - fn specification_version(&self) -> &'static str { - "v4" - } - /// Provider name, e.g. `"openai"`. fn provider(&self) -> &str; diff --git a/aimux-core/src/video_model.rs b/aimux-core/src/video_model.rs index 5b80c5d9f..f28c0c1fc 100644 --- a/aimux-core/src/video_model.rs +++ b/aimux-core/src/video_model.rs @@ -287,11 +287,6 @@ impl Default for VideoPollConfig { /// same operation reference. #[async_trait] pub trait VideoModel: Send + Sync { - /// Specification version (always `"v4"`). - fn specification_version(&self) -> &'static str { - "v4" - } - /// Provider name, e.g. `"fal"`. fn provider(&self) -> &str; diff --git a/aimux-core/tests/provider_trait_test.rs b/aimux-core/tests/provider_trait_test.rs new file mode 100644 index 000000000..e2ced858a --- /dev/null +++ b/aimux-core/tests/provider_trait_test.rs @@ -0,0 +1,151 @@ +//! Shape tests for the `Provider` trait (AI SDK `ProviderV4`). +//! +//! A provider that only offers language models must answer the two other +//! required constructors with `NoSuchModel { model_type }`, and every optional +//! constructor with `None`. + +use std::sync::Arc; + +use async_trait::async_trait; + +use aimux_core::error::AiMuxError; +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateResult, StreamResult}; +use aimux_core::{EmbeddingModel, ImageModel, LanguageModel, Provider}; + +struct OnlyLanguageModel { + model_id: String, +} + +#[async_trait] +impl LanguageModel for OnlyLanguageModel { + fn provider(&self) -> &str { + "only-lm.chat" + } + + fn model_id(&self) -> &str { + &self.model_id + } + + async fn do_generate(&self, _options: &CallOptions) -> Result { + Err(AiMuxError::Other("not used in this test".into())) + } + + async fn do_stream(&self, _options: &CallOptions) -> Result { + Err(AiMuxError::Other("not used in this test".into())) + } +} + +struct OnlyLanguageProvider; + +impl Provider for OnlyLanguageProvider { + fn language_model(&self, id: &str) -> Result, AiMuxError> { + Ok(Arc::new(OnlyLanguageModel { + model_id: id.to_string(), + })) + } + + fn embedding_model(&self, id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(id, "embeddingModel")) + } + + fn image_model(&self, id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(id, "imageModel")) + } +} + +#[test] +fn language_model_is_served() { + let model = OnlyLanguageProvider.language_model("m-1").unwrap(); + assert_eq!(model.model_id(), "m-1"); + assert_eq!(model.provider(), "only-lm.chat"); +} + +#[test] +fn unsupported_embedding_model_is_no_such_model() { + match OnlyLanguageProvider.embedding_model("e-1") { + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, "e-1"); + assert_eq!(model_type, "embeddingModel"); + } + _ => panic!("expected NoSuchModel for embeddingModel"), + } +} + +#[test] +fn unsupported_image_model_is_no_such_model() { + match OnlyLanguageProvider.image_model("i-1") { + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, "i-1"); + assert_eq!(model_type, "imageModel"); + } + _ => panic!("expected NoSuchModel for imageModel"), + } +} + +#[test] +fn optional_constructors_default_to_none() { + let p = OnlyLanguageProvider; + assert!(p.speech_model("s").is_none()); + assert!(p.transcription_model("t").is_none()); + assert!(p.reranking_model("r").is_none()); + assert!(p.video_model("v").is_none()); + assert!(p.search_model("q").is_none()); + assert!(p.files().is_none()); +} + +#[test] +fn provider_is_object_safe() { + let p: Arc = Arc::new(OnlyLanguageProvider); + assert!(p.language_model("m").is_ok()); +} + +#[test] +fn supported_urls_default_is_empty() { + let model = OnlyLanguageProvider.language_model("m").unwrap(); + let urls = model.supported_urls(); + assert!(urls.is_empty()); + assert!(urls.0.is_empty()); +} + +#[test] +fn supported_urls_can_be_overridden_per_media_type() { + use aimux_core::language_model::SupportedUrls; + + struct Images; + + #[async_trait] + impl LanguageModel for Images { + fn provider(&self) -> &str { + "images.chat" + } + fn model_id(&self) -> &str { + "m" + } + async fn do_generate(&self, _o: &CallOptions) -> Result { + Err(AiMuxError::Other("unused".into())) + } + async fn do_stream(&self, _o: &CallOptions) -> Result { + Err(AiMuxError::Other("unused".into())) + } + fn supported_urls(&self) -> SupportedUrls { + let mut map = std::collections::HashMap::new(); + map.insert( + "image/*".to_string(), + vec![regex::Regex::new(r"^https://cdn\.example\.com/").unwrap()], + ); + SupportedUrls(map) + } + } + + let urls = Images.supported_urls(); + assert!(!urls.is_empty()); + assert!(urls.0["image/*"][0].is_match("https://cdn.example.com/a.png")); + assert!(!urls.0["image/*"][0].is_match("https://other.example.com/a.png")); +} diff --git a/aimux-ffi/aimux-ffi.h b/aimux-ffi/aimux-ffi.h index 2374ce3e2..ea4a072c6 100644 --- a/aimux-ffi/aimux-ffi.h +++ b/aimux-ffi/aimux-ffi.h @@ -177,9 +177,10 @@ aimux_error_t *aimux_azure_new_with_base(const char *api_key, const char *base_u * a C ABI failure. * @param model_id NUL-terminated model ID. * @param config_json Optional JSON object of ProviderOptions - * ({"base_url": "...", "headers": {...}, "max_retries": 0, - * "body_overrides": {...}}); - * NULL / empty / "null" for defaults. + * ({"base_url": "...", "headers": {...}, "organization": "...", + * "project": "..."}); "max_retries" (call-level) and + * "body_overrides" (removed) are rejected as invalid + * arguments. NULL / empty / "null" for defaults. * @param out_handle Receives the model handle. AiMuxError: unknown * provider, bad config, missing env key, invalid model id. */ diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index b557c14f6..cf74a183f 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -82,7 +82,7 @@ use aimux_providers::openai::{OpenAIConfig, OpenAIProvider}; use aimux_providers::tavily::{TavilyConfig, TavilyProvider}; use aimux_providers::vertex::{VertexProvider, VertexProviderConfig}; use aimux_providers::xai::{XAIConfig, XAIProvider}; -use aimux_providers::{ProviderOptions, provider, provider_handle}; +use aimux_providers::{ProviderOptions, provider, provider_discovery, provider_handle}; use futures::StreamExt; use tokio::runtime::Runtime; @@ -96,7 +96,7 @@ use tokio::runtime::Runtime; #[derive(Clone)] enum HandleEntry { Language(Arc), - Provider(Arc), + Provider(ProviderEntry), Embedding(Arc), Speech(Arc), Image(Arc), @@ -110,6 +110,16 @@ enum HandleEntry { Abort(AbortSignal), } +/// A provider handle: the model factory plus its runtime-discovery side. +/// +/// `Provider` has no `list_models` (AI SDK `ProviderV4` has none); discovery +/// is the separate `ProviderDiscovery` trait, so the handle keeps both. +#[derive(Clone)] +struct ProviderEntry { + provider: Arc, + discovery: Arc, +} + type Registry = HashMap; static REGISTRY: OnceLock> = OnceLock::new(); @@ -1053,7 +1063,7 @@ pub extern "C" fn aimux_openai_new( with_out_handle(out_handle, || { let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; let m = OpenAIProvider::new(OpenAIConfig::new(api_key)).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1074,7 +1084,7 @@ pub extern "C" fn aimux_openai_new_with_base( config = config.with_base_url(url); } let m = OpenAIProvider::new(config).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1088,7 +1098,7 @@ pub extern "C" fn aimux_anthropic_new( with_out_handle(out_handle, || { let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; let m = AnthropicProvider::new(AnthropicConfig::new(api_key)).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1109,7 +1119,7 @@ pub extern "C" fn aimux_anthropic_new_with_base( config = config.with_base_url(url); } let m = AnthropicProvider::new(config).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1128,7 +1138,7 @@ pub extern "C" fn aimux_anthropic_aws_new( let m = AnthropicAwsProvider::new(AnthropicAwsProviderConfig::with_api_key(api_key, region)) .language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1150,7 +1160,7 @@ pub extern "C" fn aimux_anthropic_aws_new_with_base( config = config.with_base_url(url); } let m = AnthropicAwsProvider::new(config).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1177,7 +1187,7 @@ pub extern "C" fn aimux_azure_new( config = config.with_api_version(v); } let m = AzureProvider::new(config)?.language_model(&deployment)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1202,7 +1212,7 @@ pub extern "C" fn aimux_azure_new_with_base( config = config.with_api_version(v); } let m = AzureProvider::new(config)?.language_model(&deployment)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1232,7 +1242,7 @@ pub extern "C" fn aimux_bedrock_new( region, )) .language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1262,7 +1272,7 @@ pub extern "C" fn aimux_bedrock_new_with_base( config = config.with_base_url(url); } let m = BedrockProvider::new(config).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1288,7 +1298,7 @@ pub extern "C" fn aimux_vertex_new( )?; let m = VertexProvider::new(VertexProviderConfig::new(access_token, project, location)) .language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1318,7 +1328,7 @@ pub extern "C" fn aimux_vertex_new_with_base( config = config.with_base_url(url); } let m = VertexProvider::new(config).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1332,7 +1342,7 @@ pub extern "C" fn aimux_cohere_new( with_out_handle(out_handle, || { let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; let m = CohereProvider::new(CohereConfig::new(api_key)).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1351,7 +1361,7 @@ pub extern "C" fn aimux_cohere_new_with_base( config = config.with_base_url(url); } let m = CohereProvider::new(config).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1365,7 +1375,7 @@ pub extern "C" fn aimux_mistral_new( with_out_handle(out_handle, || { let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; let m = MistralProvider::new(MistralConfig::new(api_key)).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1384,7 +1394,7 @@ pub extern "C" fn aimux_mistral_new_with_base( config = config.with_base_url(url); } let m = MistralProvider::new(config).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1398,7 +1408,7 @@ pub extern "C" fn aimux_xai_new( with_out_handle(out_handle, || { let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; let m = XAIProvider::new(XAIConfig::new(api_key)).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1417,7 +1427,7 @@ pub extern "C" fn aimux_xai_new_with_base( config = config.with_base_url(url); } let m = XAIProvider::new(config).language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1428,8 +1438,9 @@ pub extern "C" fn aimux_xai_new_with_base( /// entry (replaces the retired `aimux_deepseek_new` etc.). /// - `model_id` — model id string. /// - `config_json` — optional JSON object of `ProviderOptions` -/// (`{"base_url": "...", "headers": {...}, "max_retries": 0, "body_overrides": {...}}`); -/// NULL / empty / "null" for defaults. +/// (`{"base_url": "...", "headers": {...}, "organization": "...", "project": "..."}`); +/// NULL / empty / "null" for defaults. `max_retries` (call-level) and +/// `body_overrides` (removed) are rejected as invalid arguments. /// /// AiMuxError: unknown provider, bad config shape, missing env key, or /// invalid model id. @@ -1448,7 +1459,7 @@ pub extern "C" fn aimux_provider_new( let key = opt_str_arg(api_key, "api_key")?; let opts = parse_provider_options(config_json)?; let m = provider(&name, key, &model_id, opts)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1464,7 +1475,7 @@ pub extern "C" fn aimux_provider_from_env( let name = str_arg(name, "name")?; let model_id = str_arg(model_id, "model_id")?; let m = provider(&name, None, &model_id, None)?; - Ok(intern_model(Arc::from(m))) + Ok(intern_model(m)) }) } @@ -1492,8 +1503,12 @@ pub extern "C" fn aimux_provider_handle_new( let name = str_arg(name, "name")?; let key = opt_str_arg(api_key, "api_key")?; let opts = parse_provider_options(config_json)?; - let p = provider_handle(&name, key, opts)?; - Ok(intern_handle(HandleEntry::Provider(Arc::from(p)))) + let provider = provider_handle(&name, key.clone(), opts.clone())?; + let discovery = provider_discovery(&name, key, opts)?; + Ok(intern_handle(HandleEntry::Provider(ProviderEntry { + provider, + discovery, + }))) }) } @@ -1515,7 +1530,7 @@ pub extern "C" fn aimux_provider_list_models( } .into()); }; - run_json(p.list_models()) + run_json(p.discovery.list_models()) }) } @@ -1538,8 +1553,8 @@ pub extern "C" fn aimux_provider_model( .into()); }; let model_id = str_arg(model_id, "model_id")?; - let m = p.language_model(&model_id)?; - Ok(intern_model(Arc::from(m))) + let m = p.provider.language_model(&model_id)?; + Ok(intern_model(m)) }) } diff --git a/aimux-ffi/tests/exports_smoke_test.rs b/aimux-ffi/tests/exports_smoke_test.rs index 620b9252f..e67906b98 100644 --- a/aimux-ffi/tests/exports_smoke_test.rs +++ b/aimux-ffi/tests/exports_smoke_test.rs @@ -494,7 +494,7 @@ fn constructor_exports_build_and_release_handles() { ); // Registry-backed constructors (RFC-0017 / RFC-0027). - let provider_opts = c(r#"{"base_url":"http://127.0.0.1:1","max_retries":0}"#); + let provider_opts = c(r#"{"base_url":"http://127.0.0.1:1"}"#); ctor!( "provider_new", aimux_provider_new( @@ -899,12 +899,12 @@ fn transcription_session_reaches_clean_terminal_error() { #[test] fn provider_discovery_and_catalogue_exports_fail_cleanly() { - // Provider handle bound to the unreachable host with retries disabled. + // Provider handle bound to the unreachable host. let mut provider = 0; let e = aimux_provider_handle_new( c("groq").as_ptr(), c(FAKE_KEY).as_ptr(), - c(r#"{"base_url":"http://127.0.0.1:1","max_retries":0}"#).as_ptr(), + c(r#"{"base_url":"http://127.0.0.1:1"}"#).as_ptr(), &mut provider, ); expect_handle(e, provider, "provider_handle_new"); @@ -1285,3 +1285,26 @@ fn tool_call_repair_exports_reject_bad_arguments() { expect_failure(e, "apply_tool_call_repair (unknown reply tag)"); assert!(out.is_null()); } + +/// `max_retries` (call-level) and `body_overrides` (removed) in a provider +/// `config_json` are invalid arguments, not silently ignored. +#[test] +fn provider_config_json_rejects_removed_keys() { + for (config, key) in [ + (r#"{"max_retries":0}"#, "max_retries"), + (r#"{"body_overrides":{"a":1}}"#, "body_overrides"), + ] { + let mut handle = 7; + let e = aimux_provider_new( + c("groq").as_ptr(), + c(FAKE_KEY).as_ptr(), + c("llama-3.3-70b").as_ptr(), + c(config).as_ptr(), + &mut handle, + ); + assert_eq!(handle, 0, "failure writes the sentinel"); + let (code, m) = expect_aimux_error(e, "provider_new (removed config key)"); + assert_eq!(code, AIMUX_E_INVALID_ARGUMENT, "{m}"); + assert!(m.contains(key), "message names `{key}`: {m}"); + } +} diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index c6e98ca52..df51d0402 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -2193,12 +2193,6 @@ pub fn build_request_body_with_warnings( // providerOptions.anthropic.contextManagement → context_management is not // yet implemented (build_context_management pending). - // Per-call request body overrides (RFC-0017): deep-merge user-supplied - // JSON into the built body. `null` values delete the corresponding key. - if let Some(ref overrides) = options.body_overrides { - crate::openai::convert::deep_merge_json(&mut body, overrides); - } - Ok(RequestBodyResult { body, warnings, diff --git a/aimux-providers/src/anthropic/mod.rs b/aimux-providers/src/anthropic/mod.rs index 1e440bd12..5a27d4bca 100644 --- a/aimux-providers/src/anthropic/mod.rs +++ b/aimux-providers/src/anthropic/mod.rs @@ -13,11 +13,15 @@ pub mod usage; use std::collections::HashMap; +use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; +use aimux_core::files_model::Files; +use aimux_core::image_model::ImageModel; use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::{Provider, ProviderDiscovery}; use aimux_provider_utils::load_api_key; use serde_json::Value; +use std::sync::Arc; pub(crate) fn anthropic_failed_response_handler() -> aimux_provider_utils::ResponseHandler { @@ -58,9 +62,9 @@ pub struct AnthropicConfig { pub headers: Option>, /// Provider 级请求体覆盖(RFC-0017)。在标准请求体之后 deep-merge。 pub body_overrides: Option, - /// api_key 来源(RFC-0023 `ProviderRecord.api_key_source`):`None` = 显式 - /// (config_snapshot 记为 "explicit");`Some("env:VAR")` = 来自环境变量; - /// `Some("none")` = 本地/匿名占位。不存明文之外的信息。 + /// api_key 来源标签:`None` = 显式传入;`Some("env:VAR")` = 来自环境变量; + /// `Some("none")` = 本地/匿名占位。仅作信息保留——录制只记 provider/model + /// 身份,不再读它。 pub api_key_source: Option, } @@ -277,14 +281,24 @@ impl AnthropicProvider { } impl Provider for AnthropicProvider { - fn name(&self) -> &str { - "anthropic" + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.model(model_id))) } - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(model_id, "embeddingModel")) } + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(model_id, "imageModel")) + } + + fn files(&self) -> Option> { + Some(Arc::new(self.files())) + } +} + +impl ProviderDiscovery for AnthropicProvider { /// List models via `GET {base_url}/v1/models` (Anthropic native), enriched /// with the community catalogue portrait when available (RFC-0027). fn list_models( diff --git a/aimux-providers/src/anthropic/model.rs b/aimux-providers/src/anthropic/model.rs index 6d0310698..8c8b4a0e4 100644 --- a/aimux-providers/src/anthropic/model.rs +++ b/aimux-providers/src/anthropic/model.rs @@ -17,6 +17,7 @@ use super::AnthropicConfig; use super::convert::build_request_body_with_warnings; use super::stream::{BodyEncoding, anthropic_generate_core, anthropic_stream_core}; use super::tool_name_mapping::ToolNameMapping; +use crate::openai::convert::apply_body_overrides; /// An Anthropic language model (e.g. `claude-sonnet-4-20250514`). pub struct AnthropicModel { @@ -90,33 +91,9 @@ impl LanguageModel for AnthropicModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.base_url.clone()), - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - // RFC-0023 C6: ProviderOptions-shaped object (headers/body_overrides - // are the shared ProviderOptions fields) plus the - // Anthropic-specific `api_version`. Sensitive headers are redacted - // at the recording boundary (recording.rs redacts provider_options). - provider_options: Some(serde_json::json!({ - "headers": self.config.headers, - "api_version": self.config.api_version, - "body_overrides": self.config.body_overrides, - })), - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { - let options = merge_anthropic_body_overrides(options, &self.config.body_overrides); - let req = build_request_body_with_warnings(&self.model_id, &options, false)?; + let mut req = build_request_body_with_warnings(&self.model_id, options, false)?; + apply_body_overrides(&mut req.body, self.config.body_overrides.as_ref()); let endpoint = self.endpoint(); let build_headers = self.make_header_builder(options.headers.as_ref(), req.betas); anthropic_generate_core( @@ -133,8 +110,8 @@ impl LanguageModel for AnthropicModel { } async fn do_stream(&self, options: &CallOptions) -> Result { - let options = merge_anthropic_body_overrides(options, &self.config.body_overrides); - let req = build_request_body_with_warnings(&self.model_id, &options, true)?; + let mut req = build_request_body_with_warnings(&self.model_id, options, true)?; + apply_body_overrides(&mut req.body, self.config.body_overrides.as_ref()); let endpoint = self.endpoint(); let build_headers = self.make_header_builder(options.headers.as_ref(), req.betas); anthropic_stream_core( @@ -150,41 +127,3 @@ impl LanguageModel for AnthropicModel { .await } } - -/// Merge provider-level body_overrides into per-call options (RFC-0017). -fn merge_anthropic_body_overrides( - options: &CallOptions, - provider_overrides: &Option, -) -> CallOptions { - match (provider_overrides, &options.body_overrides) { - (Some(provider), Some(call)) => { - let mut merged = provider.clone(); - crate::openai::convert::deep_merge_json(&mut merged, call); - let mut opts = options.clone(); - opts.body_overrides = Some(merged); - opts - } - (Some(provider), None) => { - let mut opts = options.clone(); - opts.body_overrides = Some(provider.clone()); - opts - } - (None, _) => options.clone(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn config_snapshot_matches_model_identity() { - let config = AnthropicConfig::new("sk-test"); - let model = AnthropicModel::new("claude-sonnet-4-20250514".to_string(), config); - let snap = model.config_snapshot(); - assert_eq!(snap.provider, model.provider()); - assert_eq!(snap.model_id, model.model_id()); - assert!(snap.base_url.is_some()); - assert_eq!(snap.api_key_source, "explicit"); - } -} diff --git a/aimux-providers/src/anthropic_aws/mod.rs b/aimux-providers/src/anthropic_aws/mod.rs index 9601d7e51..b5c524ae1 100644 --- a/aimux-providers/src/anthropic_aws/mod.rs +++ b/aimux-providers/src/anthropic_aws/mod.rs @@ -16,8 +16,7 @@ //! Reference: use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::ProviderDiscovery; use aimux_provider_utils::without_trailing_slash; mod model; @@ -193,15 +192,9 @@ impl AnthropicAwsProvider { } } -impl Provider for AnthropicAwsProvider { - fn name(&self) -> &str { - "anthropic-aws" - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(AnthropicAwsProvider, language_model, |p, id| p.model(id)); +impl ProviderDiscovery for AnthropicAwsProvider { /// List models via `GET {base_url}/models` (Anthropic-compatible, RFC-0027). /// API-key auth uses a simple header; SigV4 auth is not supported for /// listing (returns `Unsupported`). diff --git a/aimux-providers/src/anthropic_aws/model.rs b/aimux-providers/src/anthropic_aws/model.rs index 6098d2646..8a924252d 100644 --- a/aimux-providers/src/anthropic_aws/model.rs +++ b/aimux-providers/src/anthropic_aws/model.rs @@ -126,33 +126,6 @@ impl LanguageModel for AnthropicAwsModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - // M2b: record identity + credential source + endpoint config. Only the - // auth *kind* is serialized — never the SigV4 credentials or api-key - // plaintext. - let auth_kind = match &self.config.auth { - AnthropicAwsAuth::ApiKey(_) => "api_key", - AnthropicAwsAuth::SigV4(_) => "sigv4", - }; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.base_url.clone()), - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: Some(serde_json::json!({ - "auth_kind": auth_kind, - "api_version": self.config.api_version, - "workspace_id": self.config.workspace_id, - })), - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let req = build_request_body_with_warnings(&self.model_id, options, false)?; let endpoint = self.endpoint(); diff --git a/aimux-providers/src/assemblyai.rs b/aimux-providers/src/assemblyai.rs index 44efc78c8..f773e0f39 100644 --- a/aimux-providers/src/assemblyai.rs +++ b/aimux-providers/src/assemblyai.rs @@ -240,7 +240,7 @@ impl TranscriptionModel for AssemblyAITranscriptionModel { // retry would re-upload the audio to retry a later stage. An // exhausted inner retry returns `AiMuxError::Retry`, which the outer // retry passes through, so `do_generate` is never replayed (§6.2). - let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); + let retries = retry::prepare_retries(None, options.abort_signal.clone()); // Step 1: Upload audio. let resp = retries diff --git a/aimux-providers/src/aws_polly.rs b/aimux-providers/src/aws_polly.rs index 5216192fd..5b6d5a408 100644 --- a/aimux-providers/src/aws_polly.rs +++ b/aimux-providers/src/aws_polly.rs @@ -21,7 +21,6 @@ use async_trait::async_trait; use serde_json::{Map, Value, json}; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_core::speech_model::{ AudioData, SpeechCallOptions, SpeechModel, SpeechRequest, SpeechResponse, SpeechResult, @@ -216,11 +215,7 @@ impl AwsPollyProvider { } } -impl Provider for AwsPollyProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } -} +crate::impl_single_modality_provider!(AwsPollyProvider, speech_model, |p, id| p.speech(id)); // ── Speech model ───────────────────────────────────────────────────────────── diff --git a/aimux-providers/src/azure/model.rs b/aimux-providers/src/azure/model.rs index 10a88f5a2..16abe5af6 100644 --- a/aimux-providers/src/azure/model.rs +++ b/aimux-providers/src/azure/model.rs @@ -23,10 +23,12 @@ use std::sync::Arc; use async_trait::async_trait; +use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; +use aimux_core::image_model::ImageModel; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::provider::Provider; +use aimux_core::provider::{Provider, ProviderDiscovery}; use aimux_core::result::{GenerateResult, StreamResult}; use aimux_provider_utils::{load_api_key, with_user_agent_suffix, without_trailing_slash}; @@ -257,14 +259,20 @@ impl AzureProvider { } impl Provider for AzureProvider { - fn name(&self) -> &str { - "azure" + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.deployment(model_id))) } - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.deployment(model_id))) + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(model_id, "embeddingModel")) } + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(model_id, "imageModel")) + } +} + +impl ProviderDiscovery for AzureProvider { /// List deployments via `GET {prefix}/deployments?api-version=...` /// (Azure OpenAI, RFC-0027). Azure lists *deployments*, not models — each /// deployment's `id` is the model_id used by `language_model`. @@ -467,27 +475,6 @@ impl LanguageModel for AzureModel { &self.deployment } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id().to_string(), - base_url: self.config.base_url.clone(), - // Azure 不记录明文 key/token;来源按构造方式标注(env/explicit)。 - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: Some(serde_json::json!({ - "resource_name": self.config.resource_name, - "api_version": self.config.api_version, - "use_deployment_based_urls": self.config.use_deployment_based_urls, - })), - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()).await?; execute_generate( @@ -497,6 +484,7 @@ impl LanguageModel for AzureModel { options, "azure", &crate::openai::OpenAICompatProfile::full(), + None, ) .await } @@ -510,6 +498,7 @@ impl LanguageModel for AzureModel { options, "azure", &crate::openai::OpenAICompatProfile::full(), + None, ) .await } @@ -578,25 +567,4 @@ mod tests { "https://other.openai.azure.com/openai/v1/chat/completions?api-version=2024-10-21" ); } - - /// RFC-0023:config_snapshot 反映 provider/model_id、base_url、api_key_source。 - #[test] - fn config_snapshot_matches_config() { - let m = AzureModel::new( - "gpt-4o".to_string(), - AzureConfig::new() - .with_base_url("https://gateway.example.com/openai") - .with_api_version("2025-04-01-preview") - .use_v1_urls() - .with_api_key("k"), - ); - let snap = m.config_snapshot(); - assert_eq!(snap.provider, "azure"); - assert_eq!(snap.model_id, "gpt-4o"); - assert_eq!( - snap.base_url.as_deref(), - Some("https://gateway.example.com/openai") - ); - assert_eq!(snap.api_key_source, "explicit"); - } } diff --git a/aimux-providers/src/azure/responses.rs b/aimux-providers/src/azure/responses.rs index e0a276788..a6e2cdabf 100644 --- a/aimux-providers/src/azure/responses.rs +++ b/aimux-providers/src/azure/responses.rs @@ -236,27 +236,6 @@ impl LanguageModel for AzureResponsesModel { &self.deployment } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id().to_string(), - base_url: self.config.base_url.clone(), - // Azure 不记录明文 key/token;来源按构造方式标注(env/explicit)。 - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: Some(serde_json::json!({ - "resource_name": self.config.resource_name, - "api_version": self.config.api_version, - "use_deployment_based_urls": self.config.use_deployment_based_urls, - })), - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()).await?; let request_result = build_responses_request_body(&self.deployment, options, false); @@ -348,29 +327,3 @@ impl LanguageModel for AzureResponsesModel { }) } } - -// ── Unit tests ─────────────────────────────────────────────────────────────── -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn config_snapshot_matches_config() { - let m = AzureResponsesModel::new( - "gpt-4o".to_string(), - AzureConfig::new() - .with_base_url("https://gateway.example.com/openai") - .with_api_version("2025-04-01-preview") - .use_v1_urls() - .with_api_key("k"), - ); - let snap = m.config_snapshot(); - assert_eq!(snap.provider, "azure.responses"); - assert_eq!(snap.model_id, "gpt-4o"); - assert_eq!( - snap.base_url.as_deref(), - Some("https://gateway.example.com/openai") - ); - assert_eq!(snap.api_key_source, "explicit"); - } -} diff --git a/aimux-providers/src/bedrock/mod.rs b/aimux-providers/src/bedrock/mod.rs index b3f927168..5f9e967d5 100644 --- a/aimux-providers/src/bedrock/mod.rs +++ b/aimux-providers/src/bedrock/mod.rs @@ -20,11 +20,15 @@ pub mod reranking; pub mod sigv4; mod types; +use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; +use aimux_core::image_model::ImageModel; use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::{Provider, ProviderDiscovery}; +use aimux_core::reranking_model::RerankingModel; use aimux_provider_utils::without_trailing_slash; use serde_json::Value; +use std::sync::Arc; pub use embedding::BedrockEmbeddingModel; pub use image::BedrockImageModel; @@ -259,14 +263,27 @@ impl BedrockProvider { } impl Provider for BedrockProvider { - fn name(&self) -> &str { - "amazon-bedrock" + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.model(model_id))) } - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.embedding_model(model_id))) } + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.image(model_id))) + } + + fn reranking_model( + &self, + model_id: &str, + ) -> Option, AiMuxError>> { + Some(Ok(Arc::new(self.reranking_model(model_id)))) + } +} + +impl ProviderDiscovery for BedrockProvider { /// List foundation models via AWS Bedrock `ListFoundationModels` API /// (SigV4-signed GET, RFC-0027). fn list_models( diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index c0e3dc181..b7f38402d 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -160,46 +160,13 @@ impl LanguageModel for BedrockModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - // M2b: record identity + credential source + region (encoded in - // base_url) + retry. Never serialize the SigV4 credentials or bearer - // token plaintext — only the auth *kind* and source marker. - let auth_kind = match &self.config.auth { - BedrockAuth::BearerToken(_) => "bearer_token", - BedrockAuth::SigV4(_) => "sigv4", - }; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.base_url.clone()), - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: Some(serde_json::json!({ - "auth_kind": auth_kind, - })), - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let body = build_request_body(&self.model_id, options); let body_str = serde_json::to_string(&body).unwrap_or_default(); let url = self.endpoint(false); let headers = self.build_headers(&body_str, &url, options.headers.as_ref())?; let resp = aimux_provider_utils::post_to_api( - HttpRequest { - url, - headers, - - abort_signal: options.abort_signal.clone(), - call_id: options.call_id.clone(), - recording_context: options.recording_context.clone(), - ..Default::default() - }, + HttpRequest::new(url, headers, options), HttpBody::Bytes(body_str.into_bytes(), "application/json".to_string()), aimux_provider_utils::create_json_response_handler(), super::bedrock_failed_response_handler(), @@ -258,15 +225,7 @@ impl LanguageModel for BedrockModel { let url = self.endpoint(true); let headers = self.build_headers(&body_str, &url, options.headers.as_ref())?; let resp = aimux_provider_utils::post_to_api( - HttpRequest { - url, - headers, - - abort_signal: options.abort_signal.clone(), - call_id: options.call_id.clone(), - recording_context: options.recording_context.clone(), - ..Default::default() - }, + HttpRequest::new(url, headers, options), HttpBody::Bytes(body_str.into_bytes(), "application/json".to_string()), bedrock_event_stream_response_handler(), super::bedrock_failed_response_handler(), diff --git a/aimux-providers/src/bedrock_mantle.rs b/aimux-providers/src/bedrock_mantle.rs index 75d847c57..d36c90bf8 100644 --- a/aimux-providers/src/bedrock_mantle.rs +++ b/aimux-providers/src/bedrock_mantle.rs @@ -16,8 +16,6 @@ //! instead, which signs requests with `service = "bedrock"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use aimux_provider_utils::load_api_key; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -102,14 +100,6 @@ impl BedrockMantleProvider { } } -impl Provider for BedrockMantleProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(BedrockMantleProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(BedrockMantleProvider); diff --git a/aimux-providers/src/black_forest_labs.rs b/aimux-providers/src/black_forest_labs.rs index 1ed8f34ca..4630a0b10 100644 --- a/aimux-providers/src/black_forest_labs.rs +++ b/aimux-providers/src/black_forest_labs.rs @@ -346,7 +346,7 @@ impl ImageModel for BlackForestLabsImageModel { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); + let retries = retry::prepare_retries(None, options.abort_signal.clone()); // Poll for result let poll_interval = bfl_opts diff --git a/aimux-providers/src/catalogue.rs b/aimux-providers/src/catalogue.rs index 33473f40f..4b5815e93 100644 --- a/aimux-providers/src/catalogue.rs +++ b/aimux-providers/src/catalogue.rs @@ -3,7 +3,7 @@ //! Provides [`get_model_specs`] — a thin function that fetches the community //! model catalogue (anya2a `dist/all.json`) and returns a [`Catalogue`] of //! [`ModelSpec`] entries. This is **deliberately decoupled** from -//! `Provider::list_models`: the host calls both independently and merges them +//! `ProviderDiscovery::list_models`: the host calls both independently and merges them //! as needed. //! //! `get_model_specs` does **no caching, no FS writes, no TTL** — it is a pure diff --git a/aimux-providers/src/codex.rs b/aimux-providers/src/codex.rs index 34f985cab..113dc4ae3 100644 --- a/aimux-providers/src/codex.rs +++ b/aimux-providers/src/codex.rs @@ -32,7 +32,7 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::provider::Provider; +use aimux_core::provider::ProviderDiscovery; use aimux_core::result::{GenerateResult, StreamResult}; use aimux_core::types::Warning; @@ -175,15 +175,9 @@ impl CodexProvider { } } -impl Provider for CodexProvider { - fn name(&self) -> &str { - "codex" - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(CodexProvider, language_model, |p, id| p.model(id)); +impl ProviderDiscovery for CodexProvider { /// List models via `GET {base_url}/models` (OpenAI-compatible), enriched /// with the community catalogue portrait when available (RFC-0027). fn list_models( @@ -380,14 +374,7 @@ impl CodexModel { let endpoint = self.endpoint(); let resp = aimux_provider_utils::post_json_to_api( - HttpRequest { - url: endpoint.clone(), - headers: build_header_list(&headers), - abort_signal: opts.abort_signal.clone(), - call_id: options.call_id.clone(), - recording_context: options.recording_context.clone(), - ..Default::default() - }, + HttpRequest::new(endpoint.clone(), build_header_list(&headers), options), body.clone(), aimux_provider_utils::create_event_source_response_handler::(), crate::openai::openai_failed_response_handler(), @@ -430,23 +417,6 @@ impl LanguageModel for CodexModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.openai.base_url.clone()), - api_key_source: self - .config - .openai - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: None, - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { match self.config.mode { CodexMode::ApiKey => self.inner().do_generate(options).await, @@ -546,22 +516,3 @@ pub async fn codex_refresh_at( expires_in_secs: data.get("expires_in").and_then(serde_json::Value::as_u64), }) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn config_snapshot_matches_model_identity() { - let config = CodexConfig::new("sk-test"); - let model = CodexModel { - model_id: "gpt-5.2-codex".to_string(), - config, - }; - let snap = model.config_snapshot(); - assert_eq!(snap.provider, model.provider()); - assert_eq!(snap.model_id, model.model_id()); - assert!(snap.base_url.is_some()); - assert_eq!(snap.api_key_source, "explicit"); - } -} diff --git a/aimux-providers/src/cohere/mod.rs b/aimux-providers/src/cohere/mod.rs index d08ed229f..2d2c0f77b 100644 --- a/aimux-providers/src/cohere/mod.rs +++ b/aimux-providers/src/cohere/mod.rs @@ -13,11 +13,15 @@ mod types; pub use embedding::CohereEmbeddingModel; pub use reranking::CohereRerankingModel; +use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; +use aimux_core::image_model::ImageModel; use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::{Provider, ProviderDiscovery}; +use aimux_core::reranking_model::RerankingModel; use aimux_provider_utils::{load_api_key, without_trailing_slash}; use serde_json::Value; +use std::sync::Arc; pub(crate) fn cohere_failed_response_handler() -> aimux_provider_utils::ResponseHandler { @@ -110,14 +114,27 @@ impl CohereProvider { } impl Provider for CohereProvider { - fn name(&self) -> &str { - "cohere" + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.model(model_id))) } - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.embedding_model(model_id))) } + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(model_id, "imageModel")) + } + + fn reranking_model( + &self, + model_id: &str, + ) -> Option, AiMuxError>> { + Some(Ok(Arc::new(self.reranking_model(model_id)))) + } +} + +impl ProviderDiscovery for CohereProvider { /// List models via `GET {base_url}/models` (Cohere v2, RFC-0027). fn list_models( &self, diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index 8387154cf..64f003390 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -98,22 +98,6 @@ impl LanguageModel for CohereModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.base_url.clone()), - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: None, - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let body_result = build_request_body(&self.model_id, options, false); let body = body_result.body.clone(); @@ -606,21 +590,3 @@ fn cohere_stream_error( ) })) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn config_snapshot_reports_config() { - let model = CohereModel::new("command-r-plus".to_string(), CohereConfig::new("test-key")); - let snapshot = model.config_snapshot(); - assert_eq!(snapshot.provider, "cohere"); - assert_eq!(snapshot.model_id, "command-r-plus"); - assert_eq!( - snapshot.base_url.as_deref(), - Some("https://api.cohere.com/v2") - ); - assert_eq!(snapshot.api_key_source, "explicit"); - } -} diff --git a/aimux-providers/src/cybertron.rs b/aimux-providers/src/cybertron.rs index c875f2356..c911f26d1 100644 --- a/aimux-providers/src/cybertron.rs +++ b/aimux-providers/src/cybertron.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl CybertronProvider { } } -impl Provider for CybertronProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(CybertronProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(CybertronProvider); diff --git a/aimux-providers/src/dataforseo.rs b/aimux-providers/src/dataforseo.rs index b0016adb1..3f2fc55e0 100644 --- a/aimux-providers/src/dataforseo.rs +++ b/aimux-providers/src/dataforseo.rs @@ -16,7 +16,6 @@ use serde::Deserialize; use serde_json::{Value, json}; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -140,11 +139,7 @@ impl DataforseoProvider { } } -impl Provider for DataforseoProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } -} +crate::impl_single_modality_provider!(DataforseoProvider, search_model, |p, _id| p.search_model()); // ── Request builder ────────────────────────────────────────────────────────── diff --git a/aimux-providers/src/docker_model_runner.rs b/aimux-providers/src/docker_model_runner.rs index de1fc1529..beba98726 100644 --- a/aimux-providers/src/docker_model_runner.rs +++ b/aimux-providers/src/docker_model_runner.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,7 @@ impl DockerModelRunnerProvider { } } -impl Provider for DockerModelRunnerProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(DockerModelRunnerProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(DockerModelRunnerProvider); diff --git a/aimux-providers/src/exa_ai.rs b/aimux-providers/src/exa_ai.rs index 61b8d4edf..d093c6258 100644 --- a/aimux-providers/src/exa_ai.rs +++ b/aimux-providers/src/exa_ai.rs @@ -11,7 +11,6 @@ use serde::Deserialize; use serde_json::{Value, json}; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -86,11 +85,7 @@ impl ExaAiProvider { } } -impl Provider for ExaAiProvider { - fn name(&self) -> &str { - "exa_ai" - } -} +crate::impl_single_modality_provider!(ExaAiProvider, search_model, |p, _id| p.search_model()); fn build_request_body(options: &SearchCallOptions) -> Value { let mut body = json!({ diff --git a/aimux-providers/src/fal.rs b/aimux-providers/src/fal.rs index e6260a5d2..e93176702 100644 --- a/aimux-providers/src/fal.rs +++ b/aimux-providers/src/fal.rs @@ -284,7 +284,7 @@ impl TranscriptionModel for FalTranscriptionModel { .await?; let job: FalJobResponse = resp.value; - let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); + let retries = retry::prepare_retries(None, options.abort_signal.clone()); // Poll for result. let raw_body: Value; @@ -590,7 +590,7 @@ impl ImageModel for FalImageModel { let rh = resp.response_headers; let rb: Value = resp.value; - let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); + let retries = retry::prepare_retries(None, options.abort_signal.clone()); let target_images: Vec = if let Some(i) = rb.get("images").and_then(|v| v.as_array()) { diff --git a/aimux-providers/src/firecrawl.rs b/aimux-providers/src/firecrawl.rs index f917f64f3..59a8a3108 100644 --- a/aimux-providers/src/firecrawl.rs +++ b/aimux-providers/src/firecrawl.rs @@ -11,7 +11,6 @@ use serde::Deserialize; use serde_json::{Value, json}; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -83,11 +82,7 @@ impl FirecrawlProvider { } } -impl Provider for FirecrawlProvider { - fn name(&self) -> &str { - "firecrawl" - } -} +crate::impl_single_modality_provider!(FirecrawlProvider, search_model, |p, _id| p.search_model()); fn build_request_body(options: &SearchCallOptions) -> Value { let mut body = json!({ diff --git a/aimux-providers/src/gaudi.rs b/aimux-providers/src/gaudi.rs index 23ab9e791..fbeb2e1f3 100644 --- a/aimux-providers/src/gaudi.rs +++ b/aimux-providers/src/gaudi.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl GaudiProvider { } } -impl Provider for GaudiProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(GaudiProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(GaudiProvider); diff --git a/aimux-providers/src/gladia.rs b/aimux-providers/src/gladia.rs index 17b9e286e..accbd486a 100644 --- a/aimux-providers/src/gladia.rs +++ b/aimux-providers/src/gladia.rs @@ -225,7 +225,7 @@ impl TranscriptionModel for GladiaTranscriptionModel { // retry would re-upload the audio to retry a later stage. An // exhausted inner retry returns `AiMuxError::Retry`, which the outer // retry passes through, so `do_generate` is never replayed (§6.2). - let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); + let retries = retry::prepare_retries(None, options.abort_signal.clone()); // Step 1: Upload audio. let mut form = MultipartForm::new(); diff --git a/aimux-providers/src/google/mod.rs b/aimux-providers/src/google/mod.rs index d0f7621b6..c664e40b7 100644 --- a/aimux-providers/src/google/mod.rs +++ b/aimux-providers/src/google/mod.rs @@ -25,11 +25,16 @@ pub use embedding::GoogleEmbeddingModel; pub use image::{GoogleImageModel, GoogleImageSettings}; pub use video::GoogleVideoModel; +use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; +use aimux_core::files_model::Files; +use aimux_core::image_model::ImageModel; use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::{Provider, ProviderDiscovery}; +use aimux_core::video_model::VideoModel; use aimux_provider_utils::{load_api_key, without_trailing_slash}; use serde_json::Value; +use std::sync::Arc; pub(crate) fn google_stream_error( error: &types::GoogleError, @@ -82,9 +87,8 @@ pub(crate) fn google_failed_response_handler() -> aimux_provider_utils::Response pub struct GoogleConfig { pub api_key: String, pub base_url: String, - /// api_key 来源(RFC-0023 `ProviderRecord.api_key_source`):`None` = 显式 - /// (config_snapshot 记为 "explicit");`Some("env:VAR")` = 来自环境变量。 - /// 不存明文之外的信息,仅用于回放重建。 + /// api_key 来源标签:`None` = 显式传入;`Some("env:VAR")` = 来自环境变量。 + /// 仅作信息保留——录制(`ProviderRecord`)只记 provider/model 身份,不再读它。 pub api_key_source: Option, } @@ -187,14 +191,28 @@ impl GoogleProvider { } impl Provider for GoogleProvider { - fn name(&self) -> &str { - "google" + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.model(model_id))) } - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.embedding_model(model_id))) } + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.image(model_id))) + } + + fn video_model(&self, model_id: &str) -> Option, AiMuxError>> { + Some(Ok(Arc::new(self.video(model_id)))) + } + + fn files(&self) -> Option> { + Some(Arc::new(self.files())) + } +} + +impl ProviderDiscovery for GoogleProvider { /// List models via `GET {base_url}/models` (Gemini native), enriched with /// the community catalogue portrait when available (RFC-0027). fn list_models( diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index da6a6b2ba..e4fd08e85 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -98,22 +98,6 @@ impl LanguageModel for GoogleModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.base_url.clone()), - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: None, - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); let (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options); @@ -925,26 +909,3 @@ fn set_provider_metadata(item: &mut GenerateContent, meta: ProviderMetadata) { } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::google::GoogleConfig; - - #[test] - fn config_snapshot_records_provider_identity() { - let config = GoogleConfig::new("sk-test"); - let model = GoogleModel::new("gemini-2.0-flash".to_string(), config); - - let snap = model.config_snapshot(); - assert_eq!(snap.provider, "google.generative-ai"); - assert_eq!(snap.model_id, "gemini-2.0-flash"); - assert_eq!( - snap.base_url.as_deref(), - Some("https://generativelanguage.googleapis.com/v1beta") - ); - assert_eq!(snap.api_key_source, "explicit"); - assert_eq!(snap.profile, None); - assert_eq!(snap.provider_options, None); - } -} diff --git a/aimux-providers/src/google_pse.rs b/aimux-providers/src/google_pse.rs index 7be6d7e66..2552df699 100644 --- a/aimux-providers/src/google_pse.rs +++ b/aimux-providers/src/google_pse.rs @@ -16,7 +16,6 @@ use serde::Deserialize; use serde_json::Value; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -120,11 +119,7 @@ impl GooglePseProvider { } } -impl Provider for GooglePseProvider { - fn name(&self) -> &str { - "google_pse" - } -} +crate::impl_single_modality_provider!(GooglePseProvider, search_model, |p, _id| p.search_model()); /// Resolve the `cx` (search-engine ID): prefer the config value, then /// `provider_options["google_pse"]["cx"]`. diff --git a/aimux-providers/src/huggingface.rs b/aimux-providers/src/huggingface.rs index 40e86e29b..12529df47 100644 --- a/aimux-providers/src/huggingface.rs +++ b/aimux-providers/src/huggingface.rs @@ -15,15 +15,13 @@ pub mod responses; use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::ProviderDiscovery; use aimux_provider_utils::load_api_key; use crate::openai::{OpenAIConfig, OpenAIModel}; const DEFAULT_BASE_URL: &str = "https://router.huggingface.co/v1"; const ENV_VAR: &str = "HUGGINGFACE_API_KEY"; -const PROVIDER_NAME: &str = "huggingface"; /// Configuration for the Hugging Face provider (wraps [`OpenAIConfig`]). #[derive(Debug, Clone)] @@ -59,11 +57,6 @@ impl HuggingFaceConfig { self.0 = self.0.with_api_key_source(source); self } - - /// 内部 `OpenAIConfig` 引用(config_snapshot 复用 OpenAI helper 用,M2b)。 - pub(crate) fn openai_config(&self) -> &OpenAIConfig { - &self.0 - } } /// Hugging Face provider — creates [`OpenAIModel`] (chat) and @@ -96,15 +89,9 @@ impl HuggingFaceProvider { } } -impl Provider for HuggingFaceProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(HuggingFaceProvider, language_model, |p, id| p.model(id)); +impl ProviderDiscovery for HuggingFaceProvider { fn list_models( &self, ) -> std::pin::Pin< diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index 72d127d88..106400a6a 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -168,15 +168,6 @@ impl LanguageModel for HuggingFaceResponsesModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - // M2b: HuggingFace wraps OpenAIConfig — reuse the OpenAI snapshot helper. - crate::openai::config_snapshot_from_config( - self.provider(), - &self.model_id, - self.config.openai_config(), - ) - } - async fn do_generate(&self, options: &CallOptions) -> Result { let request = build_request_body_with_warnings(&self.model_id, options, false)?; let body = request.body; diff --git a/aimux-providers/src/jina_ai.rs b/aimux-providers/src/jina_ai.rs index cea88fbb9..3620082a4 100644 --- a/aimux-providers/src/jina_ai.rs +++ b/aimux-providers/src/jina_ai.rs @@ -14,7 +14,6 @@ use serde::Deserialize; use serde_json::{Value, json}; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::reranking_model::{ RerankingCallOptions, RerankingDocuments, RerankingModel, RerankingRank, RerankingResponse, RerankingResult, @@ -106,11 +105,8 @@ impl JinaAiProvider { } } -impl Provider for JinaAiProvider { - fn name(&self) -> &str { - "jina_ai" - } -} +crate::impl_single_modality_provider!(JinaAiProvider, reranking_model, |p, id| p + .reranking_model(id)); /// Jina AI provider-specific reranking options. #[derive(Debug, Clone, Default)] diff --git a/aimux-providers/src/jlama.rs b/aimux-providers/src/jlama.rs index 5d9252a82..f675496c8 100644 --- a/aimux-providers/src/jlama.rs +++ b/aimux-providers/src/jlama.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl JlamaProvider { } } -impl Provider for JlamaProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(JlamaProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(JlamaProvider); diff --git a/aimux-providers/src/lib.rs b/aimux-providers/src/lib.rs index 14b1eaa65..ddb17cc6d 100644 --- a/aimux-providers/src/lib.rs +++ b/aimux-providers/src/lib.rs @@ -4,28 +4,170 @@ //! //! Each provider implements the `LanguageModel` trait from `aimux-core`. -/// Delegate `Provider::list_models` to the inner `OpenAIProvider` (field `.0`). +/// Emit `impl ProviderDiscovery for $ty`, delegating `list_models` to the inner +/// `OpenAIProvider` (field `.0`). /// /// Used by newtype providers that wrap `OpenAIProvider` (e.g. `OllamaProvider`, -/// `VllmProvider`, `VertexAiOpenaiModelsProvider`, …). Inserts the full trait -/// method signature so the wrapping provider needs no extra imports. +/// `VllmProvider`, `VertexAiOpenaiModelsProvider`, …). Spells out the full +/// trait path so the wrapping provider needs no extra imports. #[macro_export] macro_rules! delegate_list_models { - () => { - fn list_models( + ($ty:ty) => { + impl ::aimux_core::provider::ProviderDiscovery for $ty { + fn list_models( + &self, + ) -> ::std::pin::Pin< + ::std::boxed::Box< + dyn ::std::future::Future< + Output = ::std::result::Result< + ::std::vec::Vec<::aimux_core::model_catalogue::RuntimeModel>, + ::aimux_core::AiMuxError, + >, + > + ::std::marker::Send + + '_, + >, + > { + ::aimux_core::provider::ProviderDiscovery::list_models(&self.0) + } + } + }; +} + +/// One required `Provider` method that this vendor does not offer: returns +/// `NoSuchModel` with the AI SDK `modelType`. +#[doc(hidden)] +#[macro_export] +macro_rules! __unsupported_required_model { + ($method:ident, $trait:ident, $model_type:literal) => { + fn $method( + &self, + id: &str, + ) -> ::std::result::Result< + ::std::sync::Arc, + ::aimux_core::AiMuxError, + > { + ::std::result::Result::Err(::aimux_core::AiMuxError::no_such_model(id, $model_type)) + } + }; +} + +/// Emit `impl Provider for $ty` for a vendor that offers exactly one modality. +/// +/// The first argument after the type names the `Provider` method to wire +/// (`language_model`, `embedding_model`, `image_model`, `transcription_model`, +/// `speech_model`, `reranking_model`, `video_model` or `search_model`); the +/// closure-like tail binds the provider and the model id and evaluates to the +/// (infallible) concrete model. The two other required methods return +/// `NoSuchModel`; the other optional ones keep their `None` default. +/// +/// ```ignore +/// impl_single_modality_provider!(VllmProvider, language_model, |p, id| p.model(id)); +/// impl_single_modality_provider!(SerperProvider, search_model, |p, _id| p.search_model()); +/// ``` +#[macro_export] +macro_rules! impl_single_modality_provider { + ($ty:ty, language_model, |$p:ident, $id:ident| $build:expr) => { + impl ::aimux_core::Provider for $ty { + fn language_model( + &self, + $id: &str, + ) -> ::std::result::Result< + ::std::sync::Arc, + ::aimux_core::AiMuxError, + > { + let $p = self; + ::std::result::Result::Ok(::std::sync::Arc::new($build)) + } + $crate::__unsupported_required_model!( + embedding_model, + EmbeddingModel, + "embeddingModel" + ); + $crate::__unsupported_required_model!(image_model, ImageModel, "imageModel"); + } + }; + ($ty:ty, embedding_model, |$p:ident, $id:ident| $build:expr) => { + impl ::aimux_core::Provider for $ty { + $crate::__unsupported_required_model!(language_model, LanguageModel, "languageModel"); + fn embedding_model( + &self, + $id: &str, + ) -> ::std::result::Result< + ::std::sync::Arc, + ::aimux_core::AiMuxError, + > { + let $p = self; + ::std::result::Result::Ok(::std::sync::Arc::new($build)) + } + $crate::__unsupported_required_model!(image_model, ImageModel, "imageModel"); + } + }; + ($ty:ty, image_model, |$p:ident, $id:ident| $build:expr) => { + impl ::aimux_core::Provider for $ty { + $crate::__unsupported_required_model!(language_model, LanguageModel, "languageModel"); + $crate::__unsupported_required_model!( + embedding_model, + EmbeddingModel, + "embeddingModel" + ); + fn image_model( + &self, + $id: &str, + ) -> ::std::result::Result< + ::std::sync::Arc, + ::aimux_core::AiMuxError, + > { + let $p = self; + ::std::result::Result::Ok(::std::sync::Arc::new($build)) + } + } + }; + ($ty:ty, $method:ident, |$p:ident, $id:ident| $build:expr) => { + impl ::aimux_core::Provider for $ty { + $crate::__unsupported_required_model!(language_model, LanguageModel, "languageModel"); + $crate::__unsupported_required_model!( + embedding_model, + EmbeddingModel, + "embeddingModel" + ); + $crate::__unsupported_required_model!(image_model, ImageModel, "imageModel"); + $crate::__optional_model!($method, |$p, $id| $build); + } + }; +} + +/// One optional `Provider` method wired to a concrete model (see +/// [`impl_single_modality_provider!`]). +#[doc(hidden)] +#[macro_export] +macro_rules! __optional_model { + (transcription_model, |$p:ident, $id:ident| $build:expr) => { + $crate::__optional_model!(@emit transcription_model, TranscriptionModel, |$p, $id| $build); + }; + (speech_model, |$p:ident, $id:ident| $build:expr) => { + $crate::__optional_model!(@emit speech_model, SpeechModel, |$p, $id| $build); + }; + (reranking_model, |$p:ident, $id:ident| $build:expr) => { + $crate::__optional_model!(@emit reranking_model, RerankingModel, |$p, $id| $build); + }; + (video_model, |$p:ident, $id:ident| $build:expr) => { + $crate::__optional_model!(@emit video_model, VideoModel, |$p, $id| $build); + }; + (search_model, |$p:ident, $id:ident| $build:expr) => { + $crate::__optional_model!(@emit search_model, SearchModel, |$p, $id| $build); + }; + (@emit $method:ident, $trait:ident, |$p:ident, $id:ident| $build:expr) => { + fn $method( &self, - ) -> ::std::pin::Pin< - ::std::boxed::Box< - dyn ::std::future::Future< - Output = ::std::result::Result< - ::std::vec::Vec, - aimux_core::AiMuxError, - >, - > + ::std::marker::Send - + '_, + $id: &str, + ) -> ::std::option::Option< + ::std::result::Result< + ::std::sync::Arc, + ::aimux_core::AiMuxError, >, > { - self.0.list_models() + let $p = self; + ::std::option::Option::Some(::std::result::Result::Ok(::std::sync::Arc::new($build))) } }; } @@ -38,8 +180,8 @@ pub mod provider; pub mod replay; pub use provider::{ ExternalProviderEntry, ProviderOptions, ProviderProfile, is_external_provider, - load_providers_from_json, provider, provider_from_env, provider_handle, provider_names, - provider_registry_entry, register_provider, + load_providers_from_json, provider, provider_discovery, provider_from_env, provider_handle, + provider_names, provider_registry_entry, register_provider, reject_removed_provider_options, }; pub use replay::rebuild_provider; diff --git a/aimux-providers/src/linkup.rs b/aimux-providers/src/linkup.rs index 2cb7ab4a9..e0efac20e 100644 --- a/aimux-providers/src/linkup.rs +++ b/aimux-providers/src/linkup.rs @@ -15,7 +15,6 @@ use serde::Deserialize; use serde_json::{Value, json}; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -103,11 +102,7 @@ impl LinkupProvider { } } -impl Provider for LinkupProvider { - fn name(&self) -> &str { - "linkup" - } -} +crate::impl_single_modality_provider!(LinkupProvider, search_model, |p, _id| p.search_model()); /// Build the Linkup `/v1/search` request body (pure function). fn build_request_body(options: &SearchCallOptions, linkup_options: &LinkupOptions) -> Value { diff --git a/aimux-providers/src/litellm_proxy.rs b/aimux-providers/src/litellm_proxy.rs index 2928fe951..5cccba386 100644 --- a/aimux-providers/src/litellm_proxy.rs +++ b/aimux-providers/src/litellm_proxy.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl LitellmProxyProvider { } } -impl Provider for LitellmProxyProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(LitellmProxyProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(LitellmProxyProvider); diff --git a/aimux-providers/src/llamacpp.rs b/aimux-providers/src/llamacpp.rs index c621eea51..08aa693ce 100644 --- a/aimux-providers/src/llamacpp.rs +++ b/aimux-providers/src/llamacpp.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl LlamacppProvider { } } -impl Provider for LlamacppProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(LlamacppProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(LlamacppProvider); diff --git a/aimux-providers/src/llamafile.rs b/aimux-providers/src/llamafile.rs index 041107e7d..c1bd445ae 100644 --- a/aimux-providers/src/llamafile.rs +++ b/aimux-providers/src/llamafile.rs @@ -14,8 +14,6 @@ //! shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -87,14 +85,6 @@ impl LlamafileProvider { } } -impl Provider for LlamafileProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(LlamafileProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(LlamafileProvider); diff --git a/aimux-providers/src/lmstudio.rs b/aimux-providers/src/lmstudio.rs index c9decc8fa..0742b7485 100644 --- a/aimux-providers/src/lmstudio.rs +++ b/aimux-providers/src/lmstudio.rs @@ -14,8 +14,6 @@ //! the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -87,14 +85,6 @@ impl LmStudioProvider { } } -impl Provider for LmStudioProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(LmStudioProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(LmStudioProvider); diff --git a/aimux-providers/src/local.rs b/aimux-providers/src/local.rs index 528c748ca..e5070de1a 100644 --- a/aimux-providers/src/local.rs +++ b/aimux-providers/src/local.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl LocalProvider { } } -impl Provider for LocalProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(LocalProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(LocalProvider); diff --git a/aimux-providers/src/localai.rs b/aimux-providers/src/localai.rs index ff6b55d38..dade36c71 100644 --- a/aimux-providers/src/localai.rs +++ b/aimux-providers/src/localai.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl LocalaiProvider { } } -impl Provider for LocalaiProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(LocalaiProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(LocalaiProvider); diff --git a/aimux-providers/src/luma.rs b/aimux-providers/src/luma.rs index f1c681f14..2c8ee2abb 100644 --- a/aimux-providers/src/luma.rs +++ b/aimux-providers/src/luma.rs @@ -334,7 +334,7 @@ impl ImageModel for LumaImageModel { AiMuxError::InvalidResponseData("missing id in Luma response".to_string()) })? .to_string(); - let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); + let retries = retry::prepare_retries(None, options.abort_signal.clone()); // Poll for completion let mut image_url = None; diff --git a/aimux-providers/src/mistral/mod.rs b/aimux-providers/src/mistral/mod.rs index 7c7ed028a..4cc0eedd9 100644 --- a/aimux-providers/src/mistral/mod.rs +++ b/aimux-providers/src/mistral/mod.rs @@ -13,11 +13,14 @@ mod types; pub use embedding::MistralEmbeddingModel; +use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; +use aimux_core::image_model::ImageModel; use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::{Provider, ProviderDiscovery}; use aimux_provider_utils::{load_api_key, without_trailing_slash}; use serde_json::Value; +use std::sync::Arc; pub(crate) fn mistral_failed_response_handler() -> aimux_provider_utils::ResponseHandler { @@ -145,14 +148,20 @@ impl MistralProvider { } impl Provider for MistralProvider { - fn name(&self) -> &str { - "mistral" + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.model(model_id))) } - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.embedding_model(model_id))) } + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(model_id, "imageModel")) + } +} + +impl ProviderDiscovery for MistralProvider { /// List models via `GET {base_url}/models` (OpenAI-compatible, RFC-0027). fn list_models( &self, diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 71c977578..ac46dc07a 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -201,22 +201,6 @@ impl LanguageModel for MistralModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.base_url.clone()), - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: None, - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let body = build_request_body(&self.model_id, options, false); let headers = self.build_headers(options.headers.as_ref()); @@ -607,24 +591,3 @@ impl LanguageModel for MistralModel { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn config_snapshot_reports_config() { - let model = MistralModel::new( - "mistral-small-latest".to_string(), - MistralConfig::new("test-key"), - ); - let snapshot = model.config_snapshot(); - assert_eq!(snapshot.provider, "mistral"); - assert_eq!(snapshot.model_id, "mistral-small-latest"); - assert_eq!( - snapshot.base_url.as_deref(), - Some("https://api.mistral.ai/v1") - ); - assert_eq!(snapshot.api_key_source, "explicit"); - } -} diff --git a/aimux-providers/src/mistralrs.rs b/aimux-providers/src/mistralrs.rs index 22b6e8f42..84fccd0c6 100644 --- a/aimux-providers/src/mistralrs.rs +++ b/aimux-providers/src/mistralrs.rs @@ -14,8 +14,6 @@ //! `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -87,14 +85,6 @@ impl MistralrsProvider { } } -impl Provider for MistralrsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(MistralrsProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(MistralrsProvider); diff --git a/aimux-providers/src/mlx.rs b/aimux-providers/src/mlx.rs index cebacf5dd..8a65036c3 100644 --- a/aimux-providers/src/mlx.rs +++ b/aimux-providers/src/mlx.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl MlxProvider { } } -impl Provider for MlxProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(MlxProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(MlxProvider); diff --git a/aimux-providers/src/ollama.rs b/aimux-providers/src/ollama.rs index 6c78d5ebe..304b672d4 100644 --- a/aimux-providers/src/ollama.rs +++ b/aimux-providers/src/ollama.rs @@ -17,8 +17,6 @@ //! provider uses the OpenAI-compatible endpoint only. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -90,14 +88,6 @@ impl OllamaProvider { } } -impl Provider for OllamaProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(OllamaProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(OllamaProvider); diff --git a/aimux-providers/src/omlx.rs b/aimux-providers/src/omlx.rs index 56b82ceff..7aa3c8133 100644 --- a/aimux-providers/src/omlx.rs +++ b/aimux-providers/src/omlx.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl OmlxProvider { } } -impl Provider for OmlxProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(OmlxProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(OmlxProvider); diff --git a/aimux-providers/src/onnx.rs b/aimux-providers/src/onnx.rs index ed48f01f2..e2e6e7d55 100644 --- a/aimux-providers/src/onnx.rs +++ b/aimux-providers/src/onnx.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl OnnxProvider { } } -impl Provider for OnnxProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(OnnxProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(OnnxProvider); diff --git a/aimux-providers/src/oobabooba.rs b/aimux-providers/src/oobabooba.rs index b62b9ea09..003909d02 100644 --- a/aimux-providers/src/oobabooba.rs +++ b/aimux-providers/src/oobabooba.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl OobaboobaProvider { } } -impl Provider for OobaboobaProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(OobaboobaProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(OobaboobaProvider); diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 340237e52..1514b2298 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -20,7 +20,6 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ToolChoice}; -use aimux_core::provider::Provider; use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::Tool; @@ -163,9 +162,8 @@ impl OpenResponsesConfig { self } - /// 标注凭证来源(RFC-0023 回放重建用)。Open Responses 的认证由调用方经 - /// `headers` 闭包管理;此 setter 让调用方显式标注来源(如 `env:VAR`), - /// 覆盖 `config_snapshot` 对闭包的推断。 + /// 标注凭证来源(如 `env:VAR`)。Open Responses 的认证由调用方经 + /// `headers` 闭包管理;仅作信息保留——录制只记 provider/model 身份,不再读它。 #[must_use] pub fn with_api_key_source(mut self, source: Option<&str>) -> Self { self.api_key_source = source.map(std::string::ToString::to_string); @@ -193,15 +191,7 @@ impl OpenResponsesProvider { } } -impl Provider for OpenResponsesProvider { - fn name(&self) -> &str { - &self.config.provider - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } -} +crate::impl_single_modality_provider!(OpenResponsesProvider, language_model, |p, id| p.model(id)); // == Model == @@ -241,42 +231,6 @@ impl OpenResponsesModel { } headers } - - /// Resolve the credential source for `config_snapshot` (M2b). - /// - /// Priority: - /// 1. An explicit `api_key_source` set via [`OpenResponsesConfig::with_api_key_source`] - /// (e.g. `"env:VAR"`) — lets callers mark env-sourced auth precisely. - /// 2. Otherwise, inspect the `headers` closure for a known auth header - /// *key* (`authorization` / `x-api-key` / `x-goog-api-key` / `api-key`). - /// Only key names are inspected — header *values* are never recorded, so - /// no secret leaks. If an auth header is present the source is - /// `"explicit"` (caller-managed); this is the same closure already - /// invoked by `build_headers` on every request, so calling it here is - /// no riskier. - /// 3. No headers closure (and no explicit source) → `"none"` (e.g. a local - /// LM Studio server with no auth). - fn resolve_api_key_source(&self) -> String { - if let Some(source) = &self.config.api_key_source { - return source.clone(); - } - let Some(headers_factory) = &self.config.headers else { - return "none".to_string(); - }; - let headers = headers_factory(); - let has_auth = headers.keys().any(|k| { - let lower = k.to_ascii_lowercase(); - matches!( - lower.as_str(), - "authorization" | "x-api-key" | "x-goog-api-key" | "api-key" - ) - }); - if has_auth { - "explicit".to_string() - } else { - "none".to_string() - } - } } #[async_trait] @@ -289,26 +243,6 @@ impl LanguageModel for OpenResponsesModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - // M2b: generic Responses wrapper — auth is caller-managed via the - // `headers` closure. Record the real credential source: an explicit - // marker if set, else inferred from the closure (auth header key - // present → "explicit"; no closure → "none"). Only key names are - // inspected — header values (secrets) are never serialized. - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.url.clone()), - api_key_source: self.resolve_api_key_source(), - profile: None, - provider_options: Some(serde_json::json!({ - "provider_options_name": self.config.provider_options_name, - "has_headers": self.config.headers.is_some(), - })), - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); let (body, warnings) = diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index 4c018dba1..6948b29ce 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -1491,18 +1491,25 @@ pub fn build_request_body_with_warnings( ); // 厂商特化后处理已整体退役(RFC-0017 阶段 2):不内置任何厂商映射, - // thinking 注入 / effort 重映射等差异由用户 bodyOverrides 定义。 - - // Per-call request body overrides (RFC-0017): deep-merge user-supplied - // JSON into the built body. `null` values delete the corresponding key. - // Applied last so users can override anything, including vendor-specific - // fields injected above. - if let Some(ref overrides) = options.body_overrides { - deep_merge_json(&mut body, overrides); - } + // thinking 注入 / effort 重映射等差异由 provider 级 body_overrides 定义。 Ok(RequestBodyResult { body, warnings }) } + +/// Apply provider-level `body_overrides` (RFC-0017) to a built request body: +/// deep-merge the JSON patch into `body`; `null` values delete the +/// corresponding key. A no-op when `overrides` is `None`. +/// +/// Call it on the finished body, right before sending, so the override can +/// change anything the converter produced. +pub fn apply_body_overrides(body: &mut Value, overrides: Option<&Value>) { + if let Some(overrides) = overrides { + deep_merge_json(body, overrides); + } +} + +/// Deep-merge `patch` into `target`: objects merge key by key, `null` removes +/// the key, any other value replaces the target. pub fn deep_merge_json(target: &mut Value, patch: &Value) { match (target, patch) { (Value::Object(t), Value::Object(p)) => { diff --git a/aimux-providers/src/openai/mod.rs b/aimux-providers/src/openai/mod.rs index 235eb2673..51365a8fc 100644 --- a/aimux-providers/src/openai/mod.rs +++ b/aimux-providers/src/openai/mod.rs @@ -26,9 +26,15 @@ use std::collections::HashMap; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::embedding_model::EmbeddingModel; +use aimux_core::files_model::Files; +use aimux_core::image_model::ImageModel; +use aimux_core::provider::{Provider, ProviderDiscovery}; +use aimux_core::speech_model::SpeechModel; +use aimux_core::transcription_model::TranscriptionModel; use aimux_provider_utils::{load_api_key, without_trailing_slash}; use serde_json::Value; +use std::sync::Arc; pub(crate) fn openai_failed_response_handler() -> aimux_provider_utils::ResponseHandler { @@ -144,7 +150,7 @@ impl OpenAICompatProfile { } /// DeepSeek profile:特化已退役(RFC-0017 阶段 2),回归 `full()`—— - /// thinking / effort 映射等厂商差异由用户 bodyOverrides 定义。 + /// thinking / effort 映射等厂商差异由 provider 级 body_overrides 定义。 /// 保留此薄封装以维持注册表与调用方结构不变。 #[must_use] pub fn deepseek() -> Self { @@ -152,60 +158,13 @@ impl OpenAICompatProfile { } } -/// RFC-0023:从 `OpenAIConfig` 构建 `ProviderRecord`(共享给 chat/responses 两个 model)。 -pub(crate) fn config_snapshot_from_config( - provider: &str, - model_id: &str, - config: &OpenAIConfig, -) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - ProviderRecord { - provider: provider.to_string(), - model_id: model_id.to_string(), - base_url: Some(config.base_url.clone()), - // 来源字段缺失时保守记为 explicit(显式 key 最常见;不泄露明文)。 - api_key_source: config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: Some(profile_to_json(&config.profile)), - provider_options: provider_options_to_json(config), - } -} - -/// profile → JSON(`&'static str` 字段序列化为 String,回放重建时转回)。 -fn profile_to_json(p: &OpenAICompatProfile) -> serde_json::Value { - serde_json::json!({ - "supports_top_k": p.supports_top_k, - "supports_tools": p.supports_tools, - "supports_response_format": p.supports_response_format, - "stream_usage_key": p.stream_usage_key, - "max_tokens_key": p.max_tokens_key, - }) -} - -/// 可重建的 provider_options(与 `provider::ProviderOptions` 序列化形状一致, -/// rebuild_provider 直接反序列化)。base_url 已放 `ProviderRecord.base_url`,不重复。 -fn provider_options_to_json(config: &OpenAIConfig) -> Option { - let opts = crate::provider::ProviderOptions { - base_url: None, - headers: config.headers.clone(), - organization: config.org_id.clone(), - project: config.project.clone(), - max_retries: None, - body_overrides: config.body_overrides.clone(), - }; - serde_json::to_value(opts).ok() -} - /// Configuration for the OpenAI provider. #[derive(Debug, Clone)] pub struct OpenAIConfig { pub api_key: String, - /// api_key 来源(RFC-0023 `ProviderRecord.api_key_source` 分类): - /// `Some("env:VAR")` = 来自环境变量;`Some("none")` = 本地无认证占位; - /// `None` = 显式传 key(config_snapshot 记为 "explicit")。不存明文之外 - /// 的信息,仅用于回放重建(S-1 建议的来源追踪字段)。 + /// api_key 来源标签:`Some("env:VAR")` = 来自环境变量;`Some("none")` = + /// 本地无认证占位;`None` = 显式传 key。仅作信息保留——录制只记 + /// provider/model 身份,不再读它。 pub api_key_source: Option, pub base_url: String, pub org_id: Option, @@ -221,8 +180,7 @@ pub struct OpenAIConfig { /// 薄封装用 `with_profile()` 设置差异。 pub profile: OpenAICompatProfile, /// Provider 级请求体覆盖(RFC-0017)。在标准请求体 + 内置厂商 override - /// 之后 deep-merge。per-call 的 `CallOptions.body_overrides` 在此之后 - /// 再 merge(覆盖 provider 级)。 + /// 之后 deep-merge,发送前最后一步。没有调用级覆盖(`CallOptions` 已无此字段)。 pub body_overrides: Option, } @@ -374,21 +332,36 @@ impl OpenAIProvider { } impl Provider for OpenAIProvider { - fn name(&self) -> &str { - "openai" + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.model(model_id))) } - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.embedding_model(model_id))) } - /// List models via `GET {base_url}/models` (OpenAI-compatible), enriched - /// with the community catalogue portrait when available (RFC-0027). - /// - /// The provider name used for catalogue lookup is `config.provider` (the - /// registry entry name, e.g. `"deepseek"`), not the hardcoded `"openai"` - /// returned by [`name`](Provider::name) — that lets the same shared impl - /// attach the right portrait per registry-backed provider. + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.image(model_id))) + } + + fn transcription_model( + &self, + model_id: &str, + ) -> Option, AiMuxError>> { + Some(Ok(Arc::new(self.transcription(model_id)))) + } + + fn speech_model(&self, model_id: &str) -> Option, AiMuxError>> { + Some(Ok(Arc::new(self.speech(model_id)))) + } + + fn files(&self) -> Option> { + Some(Arc::new(self.files())) + } +} + +impl ProviderDiscovery for OpenAIProvider { + /// List models via `GET {base_url}/models` (OpenAI-compatible, RFC-0027). fn list_models( &self, ) -> std::pin::Pin< diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 5dfb43d91..904186592 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -25,7 +25,9 @@ use aimux_provider_utils::{ }; use super::OpenAIConfig; -use super::convert::{RequestBodyResult, build_request_body_with_warnings, parse_finish_reason}; +use super::convert::{ + RequestBodyResult, apply_body_overrides, build_request_body_with_warnings, parse_finish_reason, +}; use super::types::{ChatCompletionResponse, StreamChunk, UsageResponse}; /// An OpenAI-compatible language model. @@ -84,30 +86,6 @@ pub fn build_auth_headers(config: &super::OpenAIConfig) -> HashMap) -> CallOptions { - match (provider_overrides, &options.body_overrides) { - (Some(provider), Some(call)) => { - // Merge: provider first, then call (call wins). - let mut merged = provider.clone(); - crate::openai::convert::deep_merge_json(&mut merged, call); - let mut opts = options.clone(); - opts.body_overrides = Some(merged); - opts - } - (Some(provider), None) => { - let mut opts = options.clone(); - opts.body_overrides = Some(provider.clone()); - opts - } - (None, _) => options.clone(), - } -} - // ── Usage conversion ───────────────────────────────────────────────────────── /// Convert an OpenAI `UsageResponse` into the core `Usage` type. @@ -193,34 +171,30 @@ impl LanguageModel for OpenAIModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - super::config_snapshot_from_config(&self.config.provider, &self.model_id, &self.config) - } - async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); - let options = merge_body_overrides(options, &self.config.body_overrides); execute_generate( &self.endpoint(), &headers, &self.model_id, - &options, + options, &self.config.provider, &self.config.profile, + self.config.body_overrides.as_ref(), ) .await } async fn do_stream(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); - let options = merge_body_overrides(options, &self.config.body_overrides); execute_stream( &self.endpoint(), &headers, &self.model_id, - &options, + options, &self.config.provider, &self.config.profile, + self.config.body_overrides.as_ref(), ) .await } @@ -262,9 +236,11 @@ pub async fn execute_generate( options: &CallOptions, provider: &str, profile: &super::OpenAICompatProfile, + body_overrides: Option<&Value>, ) -> Result { - let request_result = + let mut request_result = build_request_body_with_warnings(model_id, options, false, provider, profile)?; + apply_body_overrides(&mut request_result.body, body_overrides); let body = request_result.body; let resp = aimux_provider_utils::post_json_to_api( @@ -414,9 +390,11 @@ pub async fn execute_stream( options: &CallOptions, provider: &str, profile: &super::OpenAICompatProfile, + body_overrides: Option<&Value>, ) -> Result { - let request_result = + let mut request_result = build_request_body_with_warnings(model_id, options, true, provider, profile)?; + apply_body_overrides(&mut request_result.body, body_overrides); // M9 (RFC-0016): keep the warnings computed while building the body — // they are emitted in `StreamStart` below instead of being dropped. let RequestBodyResult { body, warnings } = request_result; diff --git a/aimux-providers/src/openai/responses/mod.rs b/aimux-providers/src/openai/responses/mod.rs index 295854f07..0cabea321 100644 --- a/aimux-providers/src/openai/responses/mod.rs +++ b/aimux-providers/src/openai/responses/mod.rs @@ -111,10 +111,6 @@ impl LanguageModel for OpenAIResponsesModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - super::config_snapshot_from_config(&self.config.provider, &self.model_id, &self.config) - } - async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); let request_result = build_responses_request_body(&self.model_id, options, false); diff --git a/aimux-providers/src/openrouter.rs b/aimux-providers/src/openrouter.rs index a82a0ca06..febdda883 100644 --- a/aimux-providers/src/openrouter.rs +++ b/aimux-providers/src/openrouter.rs @@ -6,8 +6,6 @@ //! is delegated to the shared [`OpenAIProvider`]. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use aimux_provider_utils::load_api_key; use crate::openai::{ @@ -77,14 +75,6 @@ impl OpenRouterProvider { } } -impl Provider for OpenRouterProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(OpenRouterProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(OpenRouterProvider); diff --git a/aimux-providers/src/openvino.rs b/aimux-providers/src/openvino.rs index 41cc94d3a..18863c8cd 100644 --- a/aimux-providers/src/openvino.rs +++ b/aimux-providers/src/openvino.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl OpenvinoProvider { } } -impl Provider for OpenvinoProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(OpenvinoProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(OpenvinoProvider); diff --git a/aimux-providers/src/parallel_ai.rs b/aimux-providers/src/parallel_ai.rs index 41c1251e7..0536e32bb 100644 --- a/aimux-providers/src/parallel_ai.rs +++ b/aimux-providers/src/parallel_ai.rs @@ -15,7 +15,6 @@ use serde::Deserialize; use serde_json::{Value, json}; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -78,11 +77,7 @@ impl ParallelAiProvider { } } -impl Provider for ParallelAiProvider { - fn name(&self) -> &str { - "parallel_ai" - } -} +crate::impl_single_modality_provider!(ParallelAiProvider, search_model, |p, _id| p.search_model()); /// Build the Parallel AI `/v1/search` request body (pure function). /// diff --git a/aimux-providers/src/provider.rs b/aimux-providers/src/provider.rs index 67d2e6348..355cd0e6d 100644 --- a/aimux-providers/src/provider.rs +++ b/aimux-providers/src/provider.rs @@ -17,14 +17,14 @@ //! ``` use std::collections::HashMap; -use std::sync::{OnceLock, RwLock}; +use std::sync::{Arc, OnceLock, RwLock}; use serde::Deserialize; use serde_json::Value; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::{Provider, ProviderDiscovery}; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIProvider}; @@ -118,7 +118,14 @@ fn base_url_has_placeholder(base_url: &str) -> bool { } /// Per-call construction options for [`provider`] (overrides the registry entry). +/// +/// Deserializing a JSON object that carries `max_retries` or `body_overrides` +/// fails: retry is a call-level option (`CallOptions::max_retries`) and +/// request-body overrides are gone, so neither is silently ignored here. The +/// FFI `config_json` and the Node/Python configs reach this check and report it +/// as `InvalidArgument`. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "ProviderOptionsWire")] pub struct ProviderOptions { /// Override the registry base URL. pub base_url: Option, @@ -128,10 +135,65 @@ pub struct ProviderOptions { pub organization: Option, /// OpenAI project ID (`OpenAI-Project` header). pub project: Option, - /// Retry count override; `Some(0)` disables retries. - pub max_retries: Option, - /// Request-body overrides (deep-merged; RFC-0017 phase 1). - pub body_overrides: Option, +} + +/// Wire shape of [`ProviderOptions`]: the same keys plus the two removed ones, +/// kept only so their presence can be reported instead of dropped. +#[derive(Deserialize)] +struct ProviderOptionsWire { + base_url: Option, + headers: Option>, + organization: Option, + project: Option, + max_retries: Option, + body_overrides: Option, +} + +impl TryFrom for ProviderOptions { + type Error = String; + + fn try_from(wire: ProviderOptionsWire) -> Result { + if wire.max_retries.is_some() { + return Err(MAX_RETRIES_REJECTED.to_string()); + } + if wire.body_overrides.is_some() { + return Err(BODY_OVERRIDES_REJECTED.to_string()); + } + Ok(Self { + base_url: wire.base_url, + headers: wire.headers, + organization: wire.organization, + project: wire.project, + }) + } +} + +const MAX_RETRIES_REJECTED: &str = "`max_retries` is a call-level option: pass it in the call \ + options (`maxRetries`), not in the provider configuration"; + +const BODY_OVERRIDES_REJECTED: &str = "`body_overrides` is no longer supported: request-body \ + overrides were removed from the provider configuration and the call options"; + +/// Report a provider configuration that still carries `max_retries` or +/// `body_overrides`, for configuration shapes that are not [`ProviderOptions`] +/// JSON (the Node and Python `ProviderConfig`). Pass whether each key was +/// given. +/// +/// # Errors +/// +/// Returns [`AiMuxError::InvalidArgument`] naming the first removed key that +/// was given. +pub fn reject_removed_provider_options( + max_retries: bool, + body_overrides: bool, +) -> Result<(), AiMuxError> { + if max_retries { + return Err(AiMuxError::InvalidArgument(MAX_RETRIES_REJECTED.into())); + } + if body_overrides { + return Err(AiMuxError::InvalidArgument(BODY_OVERRIDES_REJECTED.into())); + } + Ok(()) } /// Build a language model for a provider by name. @@ -157,7 +219,7 @@ pub fn provider( api_key: Option, model_id: &str, options: Option, -) -> Result, AiMuxError> { +) -> Result, AiMuxError> { let p = provider_handle(name, api_key, options)?; p.language_model(model_id) } @@ -359,8 +421,8 @@ impl ResolvedEntry { /// /// Unlike [`provider`] (which binds to a single `model_id` and returns a /// `LanguageModel`), this returns the [`Provider`] itself, so callers can call -/// [`Provider::list_models`] for runtime discovery, then -/// [`Provider::language_model`] on a chosen id. +/// [`Provider::language_model`] on a chosen id. For runtime model discovery +/// build the handle with [`provider_discovery`] instead. /// /// Same key/options semantics as [`provider`]. /// @@ -373,9 +435,33 @@ pub fn provider_handle( name: impl AsRef, api_key: Option, options: Option, -) -> Result, AiMuxError> { - let name = name.as_ref(); +) -> Result, AiMuxError> { + resolve_provider(name.as_ref(), api_key, options).map(|p| p as Arc) +} + +/// Build a **discovery handle** for a built-in or externally-registered +/// provider by name: the [`ProviderDiscovery`] side of [`provider_handle`] +/// (RFC-0027), used to call [`ProviderDiscovery::list_models`]. +/// +/// Same lookup, key and options semantics as [`provider_handle`]. +/// +/// # Errors +/// +/// Same as [`provider_handle`]. +pub fn provider_discovery( + name: impl AsRef, + api_key: Option, + options: Option, +) -> Result, AiMuxError> { + resolve_provider(name.as_ref(), api_key, options).map(|p| p as Arc) +} +/// Shared lookup behind [`provider_handle`] and [`provider_discovery`]. +fn resolve_provider( + name: &str, + api_key: Option, + options: Option, +) -> Result, AiMuxError> { // 1. Runtime overlay (RFC-0020) — registered entries take precedence. let resolved = if let Some(ext) = overlays().read().unwrap().get(name) { ResolvedEntry::from_external(ext) @@ -411,7 +497,7 @@ pub fn provider_handle( let mut config = build_resolved_config(&resolved, key, options); config = config.with_api_key_source(source.as_deref()); - Ok(Box::new(OpenAIProvider::new(config))) + Ok(Arc::new(OpenAIProvider::new(config))) } /// Resolve the api key for a [`ResolvedEntry`]. Returns `(key, source)` where @@ -501,11 +587,6 @@ fn build_resolved_config( if let Some(project) = opts.project { config = config.with_project(project); } - // TODO(A3): `opts.max_retries` is no longer read (retry is - // call-level); reject it with InvalidArgument (RFC-0036 D-g). - if let Some(overrides) = opts.body_overrides { - config = config.with_body_overrides(overrides); - } } config } @@ -541,7 +622,7 @@ pub fn provider_from_env( name: impl AsRef, model_id: &str, options: Option, -) -> Result, AiMuxError> { +) -> Result, AiMuxError> { provider(name, None, model_id, options) } @@ -760,15 +841,43 @@ mod tests { clear_overlay("test-relay-new"); } - #[test] - fn external_provider_overrides_builtin() { + #[tokio::test] + async fn external_provider_overrides_builtin() { + use aimux_core::content::ContentPart; + use aimux_core::language_model_message::LanguageModelPromptMessage; + use aimux_core::message::Role; + use aimux_core::options::CallOptions; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + // Register an overlay for the built-in "groq" name with a different // base_url; provider() must resolve to the overlay, not the registry. + // The registry entry for groq points at api.groq.com, so a request + // that reaches the mock server proves which path ran. + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1711115037, + "model": "llama-3.3-70b", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "hi" }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + }))) + .expect(1) + .mount(&server) + .await; + clear_overlay("groq"); // hermetic start (other tests may use "groq") register_provider(ExternalProviderEntry { name: "groq".into(), display: Some("Groq Override".into()), - base_url: "https://my-groq-relay.example/v1".into(), + base_url: server.uri(), env_var: Some("GROQ_API_KEY".into()), api_key: Some("dummy".into()), protocol: "openai_compat".into(), @@ -781,18 +890,17 @@ mod tests { comment: None, }) .unwrap(); - // Actually call provider() (not just read the overlay map) to verify - // the lookup path routes to the overlay. The registry entry for groq - // has base_url "https://api.groq.com/openai/v1"; the overlay's is - // different, so config_snapshot().base_url tells us which path ran. let model = provider("groq", None, "llama-3.3-70b", None).unwrap(); - let snap = model.config_snapshot(); - assert_eq!( - snap.base_url.as_deref(), - Some("https://my-groq-relay.example/v1"), - "provider() must resolve via the overlay, not the built-in registry" - ); + let result = model + .do_generate(&CallOptions::new(vec![LanguageModelPromptMessage { + role: Role::User, + content: vec![ContentPart::text("Hello")], + ..Default::default() + }])) + .await; clear_overlay("groq"); + result.expect("provider() must resolve via the overlay, not the built-in registry"); + // `expect(1)` above is verified when `server` drops. } #[test] diff --git a/aimux-providers/src/recraft.rs b/aimux-providers/src/recraft.rs index 4e53f0765..4a4efa307 100644 --- a/aimux-providers/src/recraft.rs +++ b/aimux-providers/src/recraft.rs @@ -18,7 +18,6 @@ use aimux_core::error::AiMuxError; use aimux_core::image_model::{ ImageCallOptions, ImageModel, ImageOutputs, ImageResponse, ImageResult, }; -use aimux_core::provider::Provider; use aimux_core::retry; use aimux_core::shared::Warning; use aimux_provider_utils::{HttpRequest, load_api_key, without_trailing_slash}; @@ -106,11 +105,7 @@ impl RecraftProvider { } } -impl Provider for RecraftProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } -} +crate::impl_single_modality_provider!(RecraftProvider, image_model, |p, id| p.image(id)); /// A Recraft image generation model. pub struct RecraftImageModel { @@ -343,7 +338,7 @@ impl ImageModel for RecraftImageModel { let response_headers = resp.response_headers; let value: Value = resp.value; - let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); + let retries = retry::prepare_retries(None, options.abort_signal.clone()); let images = extract_images( &value, diff --git a/aimux-providers/src/replay.rs b/aimux-providers/src/replay.rs index 3a8b633d6..39b90fe5c 100644 --- a/aimux-providers/src/replay.rs +++ b/aimux-providers/src/replay.rs @@ -1,199 +1,53 @@ -//! RFC-0023 请求回放(P4,层 2):按 `ProviderRecord` 自动重建 provider。 +//! RFC-0023 请求回放(P4,层 2):按 `ProviderRecord` 重建 model。 //! //! 拆层(评审 R4,2026-08-06):自动构造需要 `aimux-providers` 的构造能力, //! 放这里避免 core→providers→core 循环依赖。core 侧的 `replay_with_model` -//! 是 provider 无关的输入重建 + 重发;本模块负责"按录制配置重建 model"。 +//! 是 provider 无关的输入重建 + 重发;本模块负责"按录制身份重建 model"。 //! -//! **能力边界(定稿 R8)**:MVP 仅覆盖 **OpenAI 兼容族**——`LanguageModel::provider()` -//! 返回协议实现名,所有 OpenAI 兼容注册表 provider 都报 `"openai"`,所以按 -//! `provider.provider == "openai"` 判定 + 录制的 `base_url` 区分具体厂商。 -//! 其他协议(anthropic/google/azure/bedrock/...)明确 `Unsupported`: -//! 调用方需传 model 实例给 [`replay_with_model`](aimux_core::replay::replay_with_model)。 -//! -//! api_key 来源(`ProviderRecord.api_key_source`): -//! - `explicit` → 必须传 `api_key`(录制不存明文,隐私强制)。 -//! - `env:VAR` → 读该环境变量。 -//! - `none`(本地模型)→ `"not-needed"` 占位。 -//! - `unknown`(provider 尚未覆盖 `config_snapshot` 时的默认)→ 用传入 -//! `api_key`,未传则报错并提示。 +//! 录制只记身份(`provider_id` / `provider` / `model_id`),不记配置 +//! (RFC-0036 §3.3)。重建只按 `provider_id` + `model_id` 走 +//! [`provider`](crate::provider::provider):base URL 与 profile 来自 registry, +//! 凭证来自调用方显式传入的 key,否则读 registry 条目的环境变量。 +//! 原生协议包(anthropic / google / bedrock …)接入 registry 之前,它们的 +//! `provider_id` 返回 `NoSuchProvider`:调用方需传 model 实例给 +//! [`replay_with_model`](aimux_core::replay::replay_with_model)。 + +use std::sync::Arc; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use aimux_core::recording::ProviderRecord; -use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIProvider}; -use crate::provider::ProviderOptions; - -/// 按 `ProviderRecord` 重建 provider(OpenAI 兼容族,MVP)。 +/// 按 `ProviderRecord` 的 `provider_id` + `model_id` 重建 model。 /// -/// - `provider.provider != "openai"` → `Unsupported`(R8:不猜测原生协议)。 -/// - `base_url` 用录制值(OpenAI 兼容族区分厂商的关键);`provider_options` -/// 中的 org/project/headers/body_overrides 一并恢复;`profile` 从录制恢复。 -/// - 注意:录制的 headers 已脱敏,真实头无法从此恢复——需要时调用方重建后 -/// 再通过 `ProviderOptions.headers` 补。 +/// `api_key` 为 `None` 时由 registry 条目的环境变量取 key。 /// /// # Errors /// -/// Returns `Unsupported` for non-OpenAI-compatible recordings, -/// `InvalidArgument` for a missing `base_url`, and key-resolution errors. +/// Returns `InvalidArgument` for an empty `provider_id` or `model_id`, +/// [`AiMuxError::NoSuchProvider`] when `provider_id` is not a registered +/// provider (including native-protocol providers, which have no registry +/// entry yet), and key-resolution errors. pub fn rebuild_provider( p: &ProviderRecord, api_key: Option<&str>, -) -> Result, AiMuxError> { - if !is_openai_compatible_provider(&p.provider) { - return Err(AiMuxError::UnsupportedFunctionality(format!( - "mock replay: rebuild_provider covers only the OpenAI-compatible family \ - (recorded provider '{}'); pass a model instance to replay_with_model", - p.provider - ))); +) -> Result, AiMuxError> { + if p.provider_id.is_empty() { + return Err(AiMuxError::InvalidArgument( + "mock replay: provider record has empty provider_id".into(), + )); } if p.model_id.is_empty() { return Err(AiMuxError::InvalidArgument( "mock replay: provider record has empty model_id".into(), )); } - - let key = resolve_api_key(p, api_key)?; - // Preserve the recorded provider name (registry entry / thin-wrapper name) - // so the rebuilt model keeps its real identity (RFC-0023 C2). Direct OpenAI - // recordings carry `"openai"` and round-trip unchanged. - let mut config = OpenAIConfig::new(key) - .with_provider(&p.provider) - .with_profile(profile_from_value(p.profile.as_ref())); - - if let Some(base_url) = &p.base_url { - config = config.with_base_url(base_url); - } - - if let Some(opts) = &p.provider_options { - // provider_options 形状未知时静默降级为默认配置(不因多余/未知字段失败)。 - if let Ok(opts) = serde_json::from_value::(opts.clone()) { - if let Some(url) = opts.base_url { - config = config.with_base_url(url); - } - if let Some(headers) = opts.headers { - config = config.with_headers(headers); - } - if let Some(org) = opts.organization { - config = config.with_org_id(org); - } - if let Some(project) = opts.project { - config = config.with_project(project); - } - if let Some(overrides) = opts.body_overrides { - config = config.with_body_overrides(overrides); - } - } - } - - OpenAIProvider::new(config).language_model(&p.model_id) -} - -/// Whether a recorded provider name belongs to the OpenAI-compatible family -/// (rebuildable via `OpenAIProvider`). After RFC-0023 C2, OpenAI-compatible -/// models surface their *real* provider name (registry entry or thin-wrapper -/// name) instead of a hardcoded `"openai"`, so the replay guard must recognise -/// all of them. Native protocols (anthropic / google / azure / bedrock / …) -/// are rejected — their snapshots are not OpenAI-shaped. -/// -/// Covers: -/// - `"openai"` (direct `OpenAIProvider`), -/// - any name in `provider_registry.json` (cloud OpenAI-compatible providers), -/// - the fixed set of local/server thin wrappers that wrap `OpenAIProvider`. -fn is_openai_compatible_provider(name: &str) -> bool { - if name == "openai" { - return true; - } - // Registry-backed cloud OpenAI-compatible providers (deepseek, groq, …). - if crate::provider::provider_registry_entry(name).is_some() { - return true; - } - // Externally-registered providers via the RFC-0020 overlay. - if crate::provider::is_external_provider(name) { - return true; - } - // Local / self-hosted inference servers that wrap `OpenAIProvider`. - // Keep in sync with the thin-wrapper modules (each sets `with_provider`). - const THIN_WRAPPERS: &[&str] = &[ - "ollama", - "lmstudio", - "vllm", - "llamafile", - "mistralrs", - "llamacpp", - "localai", - "local", - "mlx", - "omlx", - "onnx", - "oobabooba", - "openvino", - "sglang", - "xinference", - "cybertron", - "docker_model_runner", - "gaudi", - "jlama", - "litellm_proxy", - "openrouter", - ]; - THIN_WRAPPERS.contains(&name) -} - -/// 从录制 JSON 恢复 `OpenAICompatProfile`;缺失/未知字段回退默认(`full()`)。 -/// `&'static str` 字段从 String leak(bounded:每录制文件每条最多一次,同 -/// registry `profile_from_registry` 模式)。 -fn profile_from_value(v: Option<&serde_json::Value>) -> OpenAICompatProfile { - let Some(v) = v else { - return OpenAICompatProfile::full(); - }; - let s = |k: &str| { - v[k].as_str().map(|s| { - let leaked: &'static str = Box::leak(s.to_string().into_boxed_str()); - leaked - }) - }; - OpenAICompatProfile { - supports_top_k: v["supports_top_k"].as_bool().unwrap_or(true), - supports_tools: v["supports_tools"].as_bool().unwrap_or(true), - supports_response_format: v["supports_response_format"].as_bool().unwrap_or(true), - stream_usage_key: s("stream_usage_key"), - max_tokens_key: s("max_tokens_key"), - } -} - -/// 按 api_key_source 解析 key;explicit/unknown 缺 key 时报清晰错误。 -fn resolve_api_key(p: &ProviderRecord, api_key: Option<&str>) -> Result { - let source = p.api_key_source.trim(); - if let Some(var) = source.strip_prefix("env:") { - return std::env::var(var).map_err(|_| { - AiMuxError::InvalidArgument(format!( - "mock replay: env var '{var}' not set (needed to rebuild provider '{}')", - p.provider - )) - }); - } - match source { - // 录制不存明文(隐私强制),调用方必须补 key。 - "explicit" => api_key.map(str::to_string).ok_or_else(|| { - AiMuxError::InvalidArgument( - "mock replay: recording uses an explicit api key (not stored); \ - pass api_key to rebuild_provider" - .to_string(), - ) - }), - // 本地模型无需 key。 - "none" => Ok("not-needed".to_string()), - // "unknown"(默认快照)或空:用调用方给的 key,否则报错兜底。 - _ => api_key.map(str::to_string).ok_or_else(|| { - AiMuxError::InvalidArgument(format!( - "mock replay: no api key source for provider '{}' (api_key_source={:?}); \ - pass api_key or provide a model instance to replay_with_model", - p.provider, p.api_key_source - )) - }), - } + crate::provider::provider( + &p.provider_id, + api_key.map(str::to_string), + &p.model_id, + None, + ) } #[cfg(test)] @@ -201,219 +55,113 @@ mod tests { use super::*; use aimux_core::recording::Recorder; - fn openai_record(api_key_source: &str, model_id: &str) -> ProviderRecord { - ProviderRecord { - provider: "openai".into(), - model_id: model_id.into(), - base_url: Some("https://api.openai.com/v1".into()), - api_key_source: api_key_source.into(), - profile: None, - provider_options: None, - } + fn record(provider_id: &str, model_id: &str) -> ProviderRecord { + ProviderRecord::new(provider_id, provider_id, model_id) } - #[test] - fn explicit_key_builds_model() { - let p = openai_record("explicit", "gpt-4o"); - let model = rebuild_provider(&p, Some("sk-test")).unwrap(); - assert_eq!(model.provider(), "openai"); - assert_eq!(model.model_id(), "gpt-4o"); + fn register_overlay(name: &str, env_var: Option<&str>) { + crate::provider::register_provider(crate::provider::ExternalProviderEntry { + name: name.into(), + display: None, + base_url: "https://relay.test.example/v1".into(), + env_var: env_var.map(Into::into), + api_key: None, + protocol: "openai_compat".into(), + profile: crate::provider::ProviderProfile::default(), + headers: None, + organization: None, + project: None, + max_retries: None, + body_overrides: None, + comment: None, + }) + .unwrap(); } #[test] - fn explicit_key_missing_errors() { - let p = openai_record("explicit", "gpt-4o"); - let err = rebuild_provider(&p, None).err().unwrap(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err}"); - assert!(err.to_string().contains("explicit"), "{err}"); + fn rebuilds_registry_provider_by_id_and_model() { + let model = rebuild_provider(&record("groq", "llama-3.3-70b"), Some("sk-test")).unwrap(); + assert_eq!(model.provider(), "groq"); + assert_eq!(model.model_id(), "llama-3.3-70b"); } #[test] - fn env_key_reads_var() { - let p = openai_record("env:AIMUX_REPLAY_TEST_KEY", "gpt-4o"); - unsafe { std::env::set_var("AIMUX_REPLAY_TEST_KEY", "sk-env") }; - let model = rebuild_provider(&p, None).unwrap(); - assert_eq!(model.model_id(), "gpt-4o"); - unsafe { std::env::remove_var("AIMUX_REPLAY_TEST_KEY") }; + fn rebuild_ignores_recorded_provider_string() { + // Only `provider_id` selects the provider; `provider` is informational. + let p = ProviderRecord::new("deepseek", "something.else", "deepseek-chat"); + let model = rebuild_provider(&p, Some("sk-test")).unwrap(); + assert_eq!(model.provider(), "deepseek"); + assert_eq!(model.model_id(), "deepseek-chat"); } #[test] - fn env_key_missing_errors() { + fn missing_env_key_names_the_variable() { + let name = "test-replay-missing-env"; + register_overlay(name, Some("AIMUX_REPLAY_TEST_MISSING")); unsafe { std::env::remove_var("AIMUX_REPLAY_TEST_MISSING") }; - let p = openai_record("env:AIMUX_REPLAY_TEST_MISSING", "gpt-4o"); - let err = rebuild_provider(&p, None).err().unwrap(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err}"); + let err = rebuild_provider(&record(name, "m"), None).err().unwrap(); + crate::provider::clear_overlay(name); assert!( - err.to_string().contains("AIMUX_REPLAY_TEST_MISSING"), + matches!(&err, AiMuxError::LoadApiKey { env_var, .. } if env_var == "AIMUX_REPLAY_TEST_MISSING"), "{err}" ); } #[test] - fn none_source_uses_placeholder() { - let p = openai_record("none", "llama-3.3-70b"); - let model = rebuild_provider(&p, None).unwrap(); - assert_eq!(model.model_id(), "llama-3.3-70b"); - } - - #[test] - fn unknown_source_falls_back_to_caller_key() { - // provider 未覆盖 config_snapshot 时 api_key_source 为 "unknown"。 - let p = openai_record("unknown", "gpt-4o"); - let model = rebuild_provider(&p, Some("sk-caller")).unwrap(); - assert_eq!(model.model_id(), "gpt-4o"); - } - - #[test] - fn unknown_source_without_key_errors() { - let p = openai_record("unknown", "gpt-4o"); - let err = rebuild_provider(&p, None).err().unwrap(); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err}"); + fn env_key_is_read_from_registry_variable() { + let name = "test-replay-env"; + register_overlay(name, Some("AIMUX_REPLAY_TEST_KEY")); + unsafe { std::env::set_var("AIMUX_REPLAY_TEST_KEY", "sk-env") }; + let result = rebuild_provider(&record(name, "m"), None); + unsafe { std::env::remove_var("AIMUX_REPLAY_TEST_KEY") }; + crate::provider::clear_overlay(name); + assert_eq!(result.unwrap().model_id(), "m"); } #[test] - fn non_openai_protocol_is_unsupported() { - let p = ProviderRecord { - provider: "anthropic".into(), - model_id: "claude-3-5-sonnet".into(), - base_url: None, - api_key_source: "env:ANTHROPIC_API_KEY".into(), - profile: None, - provider_options: None, - }; - let err = rebuild_provider(&p, Some("sk")).err().unwrap(); + fn native_protocol_provider_is_no_such_provider() { + // Native packages have no registry entry until they get their own + // factories; callers pass a model to `replay_with_model` instead. + let err = rebuild_provider(&record("anthropic", "claude-3-5-sonnet"), Some("sk")) + .err() + .unwrap(); assert!( - matches!(err, AiMuxError::UnsupportedFunctionality(_)), + matches!(&err, AiMuxError::NoSuchProvider { provider_id } if provider_id == "anthropic"), "{err}" ); - assert!(err.to_string().contains("replay_with_model"), "{err}"); } #[test] fn external_overlay_provider_is_replayable() { - // RFC-0020 overlay: a provider registered at runtime must be - // recognized by the replay path (is_openai_compatible_provider), - // otherwise rebuild_provider returns Unsupported and replay silently - // fails for newly-registered external providers. + // RFC-0020 overlay: a provider registered at runtime is found by id. let name = "test-replay-overlay"; - crate::provider::register_provider(crate::provider::ExternalProviderEntry { - name: name.into(), - display: None, - base_url: "https://relay.test.example/v1".into(), - env_var: None, - api_key: Some("dummy".into()), - protocol: "openai_compat".into(), - profile: crate::provider::ProviderProfile::default(), - headers: None, - organization: None, - project: None, - max_retries: None, - body_overrides: None, - comment: None, - }) - .unwrap(); - assert!( - is_openai_compatible_provider(name), - "external overlay provider must be replay-recognizable" - ); - // rebuild_provider should succeed (not Unsupported) for the overlay name. - let p = ProviderRecord { - provider: name.into(), - model_id: "m".into(), - base_url: Some("https://relay.test.example/v1".into()), - api_key_source: "explicit".into(), - profile: None, - provider_options: None, - }; - let result = rebuild_provider(&p, Some("dummy")); - assert!( - result.is_ok(), - "rebuild_provider should accept overlay name" - ); - // Clean up the overlay so other tests are unaffected. + register_overlay(name, None); + let result = rebuild_provider(&record(name, "m"), Some("dummy")); crate::provider::clear_overlay(name); + let model = result.expect("rebuild_provider should accept an overlay provider id"); + assert_eq!(model.provider(), name); } #[test] fn empty_model_id_errors() { - let p = openai_record("explicit", ""); - let err = rebuild_provider(&p, Some("sk")).err().unwrap(); + let err = rebuild_provider(&record("groq", ""), Some("sk")) + .err() + .unwrap(); assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err}"); } #[test] - fn provider_options_restore_org_and_project() { - let mut p = openai_record("explicit", "gpt-4o"); - p.provider_options = Some(serde_json::json!({ - "organization": "org-123", - "project": "proj-456", - })); - let model = rebuild_provider(&p, Some("sk")).unwrap(); - assert_eq!(model.model_id(), "gpt-4o"); - } - - #[test] - fn config_snapshot_roundtrips_through_rebuild() { - // 构造带非默认 profile + base_url 的 OpenAI 兼容 model(groq 风格)。 - let config = crate::openai::OpenAIConfig::new("sk-test") - .with_base_url("https://api.groq.com/openai/v1") - .with_provider("groq") - .with_profile(crate::openai::OpenAICompatProfile::groq()); - let model = crate::openai::OpenAIProvider::new(config).model("llama-3.3-70b"); - let snap = model.config_snapshot(); - assert_eq!(snap.provider, "groq"); // RFC-0023 C2: surfaces the registry name, not "openai" - assert_eq!( - snap.base_url.as_deref(), - Some("https://api.groq.com/openai/v1") - ); - assert_eq!(snap.api_key_source, "explicit"); - assert!(snap.profile.is_some(), "profile must be recorded"); - - // 从 snapshot 重建,再 snapshot → 配置等价(round-trip)。 - let rebuilt = rebuild_provider(&snap, Some("sk-test")).unwrap(); - let snap2 = rebuilt.config_snapshot(); - assert_eq!(snap2.provider, snap.provider); - assert_eq!(snap2.model_id, snap.model_id); - assert_eq!(snap2.base_url, snap.base_url); - assert_eq!(snap2.profile, snap.profile); - assert_eq!(snap2.api_key_source, snap.api_key_source); - assert_eq!(snap2.provider_options, snap.provider_options); - } - - #[test] - fn config_snapshot_marks_env_source() { - let config = crate::openai::OpenAIConfig::from_env().unwrap_or_else(|_| { - crate::openai::OpenAIConfig::new("sk").with_api_key_source(Some("env:OPENAI_API_KEY")) - }); - let model = crate::openai::OpenAIProvider::new(config).model("gpt-4o"); - assert_eq!(model.config_snapshot().api_key_source, "env:OPENAI_API_KEY"); - } - - #[test] - fn local_wrapper_marks_none_source() { - // ollama 等本地 wrapper:占位 key → "none",回放重建无需 key。 - let config = crate::ollama::OllamaConfig::from_env().unwrap(); - let model = crate::ollama::OllamaProvider::new(config).model("llama3.2"); - let snap = model.config_snapshot(); - assert_eq!(snap.api_key_source, "none", "{snap:?}"); - // "none" 来源重建:不传 key 也应成功。 - let rebuilt = rebuild_provider(&snap, None).unwrap(); - assert_eq!(rebuilt.model_id(), "llama3.2"); + fn empty_provider_id_errors() { + let err = rebuild_provider(&record("", "m"), Some("sk")) + .err() + .unwrap(); + assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err}"); } #[test] - fn recording_boundary_redacts_sensitive_headers() { - // 脱敏验证(P5):config.headers 带 Authorization 明文,snapshot 内存中 - // 可含(录制边界为门控),但经 RingRecorder 录制导出后必须无明文。 - use std::collections::HashMap; - let mut headers = HashMap::new(); - headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string()); - headers.insert("X-Custom".to_string(), "ok".to_string()); - let config = crate::openai::OpenAIConfig::new("sk-secret").with_headers(headers); - let model = crate::openai::OpenAIProvider::new(config).model("gpt-4o"); - let snap = model.config_snapshot(); - assert!(snap.provider_options.is_some(), "headers must be recorded"); - + fn recorded_provider_identity_carries_no_configuration() { + // The record that reaches disk is identity only: no base URL, headers + // or key source, so nothing from the provider config can leak. let ring = aimux_core::recording::RingRecorder::with_capacity(8); let options = aimux_core::generate::GenerateTextOptions::default().into_call_options(vec![ aimux_core::language_model_message::LanguageModelPromptMessage { @@ -425,8 +173,8 @@ mod tests { provider_options: None, }, ]); - ring.record_input("c1", &options, "openai", "gpt-4o"); - ring.record_provider("c1", &snap); + ring.record_input("c1", &options, "openai.chat", "gpt-4o"); + ring.record_provider("c1", &ProviderRecord::from_model("openai.chat", "gpt-4o")); ring.record_outcome( "c1", &aimux_core::recording::OutcomeRecord { @@ -441,12 +189,15 @@ mod tests { let mut buf = Vec::new(); ring.export_jsonl(&mut buf).unwrap(); - let text = String::from_utf8(buf).unwrap(); - assert!(!text.contains("sk-secret"), "plaintext key leaked: {text}"); - assert!(text.contains("[REDACTED]"), "must be redacted: {text}"); - assert!( - text.contains("\"X-Custom\":\"ok\""), - "non-sensitive kept: {text}" + let line = String::from_utf8(buf).unwrap(); + let rec: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + assert_eq!( + rec["provider"], + serde_json::json!({ + "provider_id": "openai", + "provider": "openai.chat", + "model_id": "gpt-4o", + }) ); } } diff --git a/aimux-providers/src/replicate.rs b/aimux-providers/src/replicate.rs index db2ed7201..c50763947 100644 --- a/aimux-providers/src/replicate.rs +++ b/aimux-providers/src/replicate.rs @@ -320,7 +320,7 @@ impl ImageModel for ReplicateImageModel { let rh = resp.response_headers; let rb: Value = resp.value; - let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); + let retries = retry::prepare_retries(None, options.abort_signal.clone()); // Extract output (string or array of strings) let urls: Vec = match &rb["output"] { diff --git a/aimux-providers/src/revai.rs b/aimux-providers/src/revai.rs index caa8e3166..5b4426d88 100644 --- a/aimux-providers/src/revai.rs +++ b/aimux-providers/src/revai.rs @@ -265,7 +265,7 @@ impl TranscriptionModel for RevaiTranscriptionModel { ) })?; let submission_language = submit_response.language; - let retries = retry::prepare_retries(options.max_retries, options.abort_signal.clone()); + let retries = retry::prepare_retries(None, options.abort_signal.clone()); // Poll for completion. let job_status: RevaiJobResponse; diff --git a/aimux-providers/src/runwayml.rs b/aimux-providers/src/runwayml.rs index be5e8bc5b..2ec8bf8de 100644 --- a/aimux-providers/src/runwayml.rs +++ b/aimux-providers/src/runwayml.rs @@ -17,7 +17,6 @@ use serde::Deserialize; use serde_json::{Map, Value, json}; use aimux_core::error::{AiMuxError, ApiCallError}; -use aimux_core::provider::Provider; use aimux_core::video_model::{ VideoCallOptions, VideoData, VideoFile, VideoFileData, VideoFrameType, VideoModel, VideoOperationStart, VideoOperationStatus, VideoPollConfig, VideoResponse, VideoResult, @@ -125,11 +124,7 @@ impl RunwaymlProvider { } } -impl Provider for RunwaymlProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } -} +crate::impl_single_modality_provider!(RunwaymlProvider, video_model, |p, id| p.video(id)); // ── Helpers ───────────────────────────────────────────────────────────────── diff --git a/aimux-providers/src/searxng.rs b/aimux-providers/src/searxng.rs index f4e58db88..1da82c2af 100644 --- a/aimux-providers/src/searxng.rs +++ b/aimux-providers/src/searxng.rs @@ -15,7 +15,6 @@ use serde::Deserialize; use serde_json::Value; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -84,11 +83,7 @@ impl SearxngProvider { } } -impl Provider for SearxngProvider { - fn name(&self) -> &str { - "searxng" - } -} +crate::impl_single_modality_provider!(SearxngProvider, search_model, |p, _id| p.search_model()); /// A single SearXNG result entry. All fields are optional so unknown-but-legal /// values degrade safely. diff --git a/aimux-providers/src/serper.rs b/aimux-providers/src/serper.rs index 5c87a9126..264cbade7 100644 --- a/aimux-providers/src/serper.rs +++ b/aimux-providers/src/serper.rs @@ -11,7 +11,6 @@ use serde::Deserialize; use serde_json::{Value, json}; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -69,11 +68,7 @@ impl SerperProvider { } } -impl Provider for SerperProvider { - fn name(&self) -> &str { - "serper" - } -} +crate::impl_single_modality_provider!(SerperProvider, search_model, |p, _id| p.search_model()); fn build_request_body(options: &SearchCallOptions) -> Value { let mut body = json!({ diff --git a/aimux-providers/src/sglang.rs b/aimux-providers/src/sglang.rs index 56d95fabe..6ac6de882 100644 --- a/aimux-providers/src/sglang.rs +++ b/aimux-providers/src/sglang.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl SglangProvider { } } -impl Provider for SglangProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(SglangProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(SglangProvider); diff --git a/aimux-providers/src/stability.rs b/aimux-providers/src/stability.rs index d727cba94..8525dbf54 100644 --- a/aimux-providers/src/stability.rs +++ b/aimux-providers/src/stability.rs @@ -15,7 +15,6 @@ use async_trait::async_trait; use bytes::Bytes; use serde_json::Value; -use aimux_core::Provider; use aimux_core::error::AiMuxError; use aimux_core::image_model::{ ImageCallOptions, ImageModel, ImageOutputs, ImageResponse, ImageResult, @@ -162,11 +161,7 @@ impl StabilityProvider { } } -impl Provider for StabilityProvider { - fn name(&self) -> &str { - "stability" - } -} +crate::impl_single_modality_provider!(StabilityProvider, image_model, |p, id| p.image(id)); /// A Stability image generation model. pub struct StabilityImageModel { diff --git a/aimux-providers/src/tavily.rs b/aimux-providers/src/tavily.rs index a01ab7ec3..a44ff2ed9 100644 --- a/aimux-providers/src/tavily.rs +++ b/aimux-providers/src/tavily.rs @@ -10,7 +10,6 @@ use serde::Deserialize; use serde_json::{Value, json}; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -83,11 +82,7 @@ impl TavilyProvider { } } -impl Provider for TavilyProvider { - fn name(&self) -> &str { - "tavily" - } -} +crate::impl_single_modality_provider!(TavilyProvider, search_model, |p, _id| p.search_model()); fn build_request_body(options: &SearchCallOptions) -> Value { let mut body = json!({ diff --git a/aimux-providers/src/tinyfish.rs b/aimux-providers/src/tinyfish.rs index 06bb368c2..05411ef2a 100644 --- a/aimux-providers/src/tinyfish.rs +++ b/aimux-providers/src/tinyfish.rs @@ -14,7 +14,6 @@ use serde::Deserialize; use serde_json::Value; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -101,11 +100,7 @@ impl TinyfishProvider { } } -impl Provider for TinyfishProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } -} +crate::impl_single_modality_provider!(TinyfishProvider, search_model, |p, _id| p.search_model()); // ── Request builder ────────────────────────────────────────────────────────── diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index 559b91058..737907950 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -143,46 +143,13 @@ impl LanguageModel for VertexAnthropicModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - // M2b: record identity + credential source + auth kind. Never serialize - // the bearer token or API key plaintext. - let auth_kind = match &self.config.auth { - VertexAuth::BearerToken(_) => "bearer_token", - VertexAuth::ApiKey(_) => "api_key", - }; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.base_url.clone()), - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: Some(serde_json::json!({ - "auth_kind": auth_kind, - "anthropic_version": ANTHROPIC_VERTEX_VERSION, - })), - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let req = build_request_body_with_warnings(&self.model_id, options, false)?; let body = self.wrap_raw_predict_body(req.body); let url = self.generate_endpoint(); let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( - HttpRequest { - url: url.clone(), - headers, - - abort_signal: options.abort_signal.clone(), - call_id: options.call_id.clone(), - recording_context: options.recording_context.clone(), - ..Default::default() - }, + HttpRequest::new(url.clone(), headers, options), body.clone(), aimux_provider_utils::create_json_response_handler(), crate::google::google_failed_response_handler(), @@ -232,15 +199,7 @@ impl LanguageModel for VertexAnthropicModel { let url = self.stream_endpoint(); let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( - HttpRequest { - url: url.clone(), - headers, - - abort_signal: options.abort_signal.clone(), - call_id: options.call_id.clone(), - recording_context: options.recording_context.clone(), - ..Default::default() - }, + HttpRequest::new(url.clone(), headers, options), body.clone(), aimux_provider_utils::create_event_source_response_handler::(), crate::google::google_failed_response_handler(), diff --git a/aimux-providers/src/vertex/mod.rs b/aimux-providers/src/vertex/mod.rs index 859a8f47a..7fd76ab8d 100644 --- a/aimux-providers/src/vertex/mod.rs +++ b/aimux-providers/src/vertex/mod.rs @@ -14,10 +14,15 @@ //! //! Reference: +use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; +use aimux_core::image_model::ImageModel; use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::{Provider, ProviderDiscovery}; +use aimux_core::transcription_model::TranscriptionModel; +use aimux_core::video_model::VideoModel; use aimux_provider_utils::without_trailing_slash; +use std::sync::Arc; mod anthropic_model; mod embedding; @@ -320,14 +325,37 @@ impl VertexProvider { } impl Provider for VertexProvider { - fn name(&self) -> &str { - "google.vertex" + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.model(model_id)?)) } - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id)?)) + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.embedding_model(model_id))) } + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.image(model_id))) + } + + fn transcription_model( + &self, + model_id: &str, + ) -> Option, AiMuxError>> { + Some( + self.transcription(model_id) + .map(|m| Arc::new(m) as Arc), + ) + } + + fn video_model(&self, model_id: &str) -> Option, AiMuxError>> { + Some( + self.video(model_id) + .map(|m| Arc::new(m) as Arc), + ) + } +} + +impl ProviderDiscovery for VertexProvider { /// List models via `GET {base_url}/models` (Gemini native, RFC-0027). fn list_models( &self, diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 2cd4a15b4..529495d15 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -122,44 +122,12 @@ impl LanguageModel for VertexModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - use aimux_core::recording::ProviderRecord; - // M2b: record identity + credential source + auth kind. Never serialize - // the bearer token or API key plaintext. - let auth_kind = match &self.config.auth { - VertexAuth::BearerToken(_) => "bearer_token", - VertexAuth::ApiKey(_) => "api_key", - }; - ProviderRecord { - provider: self.provider().to_string(), - model_id: self.model_id.clone(), - base_url: Some(self.config.base_url.clone()), - api_key_source: self - .config - .api_key_source - .clone() - .unwrap_or_else(|| "explicit".to_string()), - profile: None, - provider_options: Some(serde_json::json!({ - "auth_kind": auth_kind, - })), - } - } - async fn do_generate(&self, options: &CallOptions) -> Result { let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); let body = build_vertex_request_body(&self.model_id, options); let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( - HttpRequest { - url: self.generate_endpoint(), - headers, - - abort_signal: options.abort_signal.clone(), - call_id: options.call_id.clone(), - recording_context: options.recording_context.clone(), - ..Default::default() - }, + HttpRequest::new(self.generate_endpoint(), headers, options), body.clone(), aimux_provider_utils::create_json_response_handler(), crate::google::google_failed_response_handler(), @@ -225,15 +193,7 @@ impl LanguageModel for VertexModel { let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.stream_endpoint(); let resp = aimux_provider_utils::post_json_to_api( - HttpRequest { - url: endpoint.clone(), - headers, - - abort_signal: options.abort_signal.clone(), - call_id: options.call_id.clone(), - recording_context: options.recording_context.clone(), - ..Default::default() - }, + HttpRequest::new(endpoint.clone(), headers, options), body.clone(), aimux_provider_utils::create_event_source_response_handler::(), crate::google::google_failed_response_handler(), diff --git a/aimux-providers/src/vertex_ai_ai21_models.rs b/aimux-providers/src/vertex_ai_ai21_models.rs index a6de8ca78..d5ee04269 100644 --- a/aimux-providers/src/vertex_ai_ai21_models.rs +++ b/aimux-providers/src/vertex_ai_ai21_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"ai21/jamba-1.5-large"`, `"ai21/jamba-1.5-mini"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiAi21ModelsProvider { } } -impl Provider for VertexAiAi21ModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiAi21ModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiAi21ModelsProvider); diff --git a/aimux-providers/src/vertex_ai_anthropic_models.rs b/aimux-providers/src/vertex_ai_anthropic_models.rs index a1717b8a2..1a7fa8bbd 100644 --- a/aimux-providers/src/vertex_ai_anthropic_models.rs +++ b/aimux-providers/src/vertex_ai_anthropic_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"anthropic/claude-sonnet-4"`, `"anthropic/claude-opus-4"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiAnthropicModelsProvider { } } -impl Provider for VertexAiAnthropicModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiAnthropicModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiAnthropicModelsProvider); diff --git a/aimux-providers/src/vertex_ai_deepseek_models.rs b/aimux-providers/src/vertex_ai_deepseek_models.rs index 1d2c5b015..e7c252f12 100644 --- a/aimux-providers/src/vertex_ai_deepseek_models.rs +++ b/aimux-providers/src/vertex_ai_deepseek_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"deepseek-ai/deepseek-v3.1-maas"`, `"deepseek-ai/deepseek-r1-0528-maas"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiDeepseekModelsProvider { } } -impl Provider for VertexAiDeepseekModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiDeepseekModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiDeepseekModelsProvider); diff --git a/aimux-providers/src/vertex_ai_llama_models.rs b/aimux-providers/src/vertex_ai_llama_models.rs index 76edcb6b6..06563b389 100644 --- a/aimux-providers/src/vertex_ai_llama_models.rs +++ b/aimux-providers/src/vertex_ai_llama_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"meta/llama-4-scout-17b-16e-instruct-maas"`, `"meta/llama-4-maverick-17b-128e-instruct-maas"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiLlamaModelsProvider { } } -impl Provider for VertexAiLlamaModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiLlamaModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiLlamaModelsProvider); diff --git a/aimux-providers/src/vertex_ai_minimax_models.rs b/aimux-providers/src/vertex_ai_minimax_models.rs index 4e2e17ffb..2e2c953c9 100644 --- a/aimux-providers/src/vertex_ai_minimax_models.rs +++ b/aimux-providers/src/vertex_ai_minimax_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"minimax/minimax-m2-maas"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiMinimaxModelsProvider { } } -impl Provider for VertexAiMinimaxModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiMinimaxModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiMinimaxModelsProvider); diff --git a/aimux-providers/src/vertex_ai_mistral_models.rs b/aimux-providers/src/vertex_ai_mistral_models.rs index 0ba7d47b2..77333fd76 100644 --- a/aimux-providers/src/vertex_ai_mistral_models.rs +++ b/aimux-providers/src/vertex_ai_mistral_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"mistralai/mistral-large-2411"`, `"mistralai/codestral-2501"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiMistralModelsProvider { } } -impl Provider for VertexAiMistralModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiMistralModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiMistralModelsProvider); diff --git a/aimux-providers/src/vertex_ai_moonshot_models.rs b/aimux-providers/src/vertex_ai_moonshot_models.rs index 63a66529f..5cbf9be19 100644 --- a/aimux-providers/src/vertex_ai_moonshot_models.rs +++ b/aimux-providers/src/vertex_ai_moonshot_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"moonshotai/kimi-k2-thinking-maas"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiMoonshotModelsProvider { } } -impl Provider for VertexAiMoonshotModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiMoonshotModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiMoonshotModelsProvider); diff --git a/aimux-providers/src/vertex_ai_openai_models.rs b/aimux-providers/src/vertex_ai_openai_models.rs index 94ac6e330..d7ad55fed 100644 --- a/aimux-providers/src/vertex_ai_openai_models.rs +++ b/aimux-providers/src/vertex_ai_openai_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"openai/gpt-oss-120b-maas"`, `"openai/gpt-oss-20b-maas"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiOpenaiModelsProvider { } } -impl Provider for VertexAiOpenaiModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiOpenaiModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiOpenaiModelsProvider); diff --git a/aimux-providers/src/vertex_ai_qwen_models.rs b/aimux-providers/src/vertex_ai_qwen_models.rs index a8ec610dd..f39d67d0e 100644 --- a/aimux-providers/src/vertex_ai_qwen_models.rs +++ b/aimux-providers/src/vertex_ai_qwen_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"qwen/qwen3-coder-480b-a35b-instruct-maas"`, `"qwen/qwen3-next-80b-a3b-instruct-maas"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiQwenModelsProvider { } } -impl Provider for VertexAiQwenModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiQwenModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiQwenModelsProvider); diff --git a/aimux-providers/src/vertex_ai_zai_models.rs b/aimux-providers/src/vertex_ai_zai_models.rs index 8470bb990..23b746ec1 100644 --- a/aimux-providers/src/vertex_ai_zai_models.rs +++ b/aimux-providers/src/vertex_ai_zai_models.rs @@ -20,8 +20,6 @@ //! Sample model ids: `"zai-org/glm-4.7-maas"`, `"zai-org/glm-5-maas"`. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -124,14 +122,7 @@ impl VertexAiZaiModelsProvider { } } -impl Provider for VertexAiZaiModelsProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VertexAiZaiModelsProvider, language_model, |p, id| p + .model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VertexAiZaiModelsProvider); diff --git a/aimux-providers/src/vllm.rs b/aimux-providers/src/vllm.rs index 38f8d752b..ac128de36 100644 --- a/aimux-providers/src/vllm.rs +++ b/aimux-providers/src/vllm.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl VllmProvider { } } -impl Provider for VllmProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(VllmProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(VllmProvider); diff --git a/aimux-providers/src/voyage/mod.rs b/aimux-providers/src/voyage/mod.rs index 7ac94c982..5977023c6 100644 --- a/aimux-providers/src/voyage/mod.rs +++ b/aimux-providers/src/voyage/mod.rs @@ -9,9 +9,14 @@ pub mod reranking; pub use embedding::VoyageEmbeddingModel; pub use reranking::VoyageRerankingModel; +use aimux_core::embedding_model::EmbeddingModel; use aimux_core::error::AiMuxError; +use aimux_core::image_model::ImageModel; +use aimux_core::language_model::LanguageModel; use aimux_core::provider::Provider; +use aimux_core::reranking_model::RerankingModel; use aimux_provider_utils::{load_api_key, without_trailing_slash}; +use std::sync::Arc; pub(crate) fn voyage_failed_response_handler() -> aimux_provider_utils::ResponseHandler { @@ -88,7 +93,22 @@ impl VoyageProvider { } impl Provider for VoyageProvider { - fn name(&self) -> &str { - "voyage" + fn language_model(&self, model_id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(model_id, "languageModel")) + } + + fn embedding_model(&self, model_id: &str) -> Result, AiMuxError> { + Ok(Arc::new(self.embedding_model(model_id))) + } + + fn image_model(&self, model_id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(model_id, "imageModel")) + } + + fn reranking_model( + &self, + model_id: &str, + ) -> Option, AiMuxError>> { + Some(Ok(Arc::new(self.reranking_model(model_id)))) } } diff --git a/aimux-providers/src/xai/mod.rs b/aimux-providers/src/xai/mod.rs index e98e52465..74ddbd5c2 100644 --- a/aimux-providers/src/xai/mod.rs +++ b/aimux-providers/src/xai/mod.rs @@ -16,8 +16,7 @@ pub use model::XaiModel; pub use responses::XaiResponsesModel; use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; +use aimux_core::provider::ProviderDiscovery; use aimux_provider_utils::load_api_key; use serde::de::DeserializeOwned; use serde_json::Value; @@ -240,11 +239,6 @@ impl XAIConfig { self } - /// 内部 `OpenAIConfig` 引用(config_snapshot 复用 OpenAI helper 用,M2b)。 - pub(crate) fn openai_config(&self) -> &OpenAIConfig { - &self.0 - } - /// Override the base URL (useful for tests / self-hosted endpoints). #[must_use] pub fn with_base_url(mut self, url: impl Into) -> Self { @@ -297,15 +291,9 @@ impl XAIProvider { } } -impl Provider for XAIProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(XAIProvider, language_model, |p, id| p.model(id)); +impl ProviderDiscovery for XAIProvider { /// List models via `GET {base_url}/models` (OpenAI-compatible, RFC-0027). fn list_models( &self, diff --git a/aimux-providers/src/xai/model.rs b/aimux-providers/src/xai/model.rs index f14367c0f..9a45b1443 100644 --- a/aimux-providers/src/xai/model.rs +++ b/aimux-providers/src/xai/model.rs @@ -92,17 +92,6 @@ impl LanguageModel for XaiModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - // M2b: xAI wraps OpenAIConfig — reuse the OpenAI snapshot helper with - // xAI's own provider name. api_key_source/profile come from the inner - // config. - crate::openai::config_snapshot_from_config( - self.provider(), - &self.model_id, - self.config.openai_config(), - ) - } - async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); let request_result = build_request_body_with_warnings(&self.model_id, options, false)?; diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index c7f81b62b..552f7df56 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -89,15 +89,6 @@ impl LanguageModel for XaiResponsesModel { &self.model_id } - fn config_snapshot(&self) -> aimux_core::recording::ProviderRecord { - // M2b: reuse the OpenAI snapshot helper with xAI's provider name. - crate::openai::config_snapshot_from_config( - self.provider(), - &self.model_id, - self.config.openai_config(), - ) - } - async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); let request_result = build_responses_request_body(&self.model_id, options, false)?; diff --git a/aimux-providers/src/xinference.rs b/aimux-providers/src/xinference.rs index 01607cb2e..4f868d328 100644 --- a/aimux-providers/src/xinference.rs +++ b/aimux-providers/src/xinference.rs @@ -7,8 +7,6 @@ //! header — the shared `OpenAIProvider` requires a non-empty key string. use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; use crate::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIModel, OpenAIProvider}; @@ -66,14 +64,6 @@ impl XinferenceProvider { } } -impl Provider for XinferenceProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } - - fn language_model(&self, model_id: &str) -> Result, AiMuxError> { - Ok(Box::new(self.model(model_id))) - } +crate::impl_single_modality_provider!(XinferenceProvider, language_model, |p, id| p.model(id)); - crate::delegate_list_models!(); -} +crate::delegate_list_models!(XinferenceProvider); diff --git a/aimux-providers/src/you_com.rs b/aimux-providers/src/you_com.rs index dcab66c32..8e331e22f 100644 --- a/aimux-providers/src/you_com.rs +++ b/aimux-providers/src/you_com.rs @@ -15,7 +15,6 @@ use serde::Deserialize; use serde_json::Value; use aimux_core::error::AiMuxError; -use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; @@ -102,11 +101,7 @@ impl YouComProvider { } } -impl Provider for YouComProvider { - fn name(&self) -> &str { - PROVIDER_NAME - } -} +crate::impl_single_modality_provider!(YouComProvider, search_model, |p, _id| p.search_model()); // ── Request builder ────────────────────────────────────────────────────────── diff --git a/aimux-providers/tests/alibaba_test.rs b/aimux-providers/tests/alibaba_test.rs index a4323ca6a..781365bd8 100644 --- a/aimux-providers/tests/alibaba_test.rs +++ b/aimux-providers/tests/alibaba_test.rs @@ -27,6 +27,7 @@ use futures::StreamExt; use serde_json::{Value, json}; use serial_test::serial; +use std::sync::Arc; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -98,7 +99,7 @@ async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec Box { +fn make_provider(server: &MockServer) -> Arc { provider( "alibaba", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/anthropic_files_test.rs b/aimux-providers/tests/anthropic_files_test.rs index 4f6127f79..cbfb50590 100644 --- a/aimux-providers/tests/anthropic_files_test.rs +++ b/aimux-providers/tests/anthropic_files_test.rs @@ -293,14 +293,6 @@ async fn handles_base64_string_data() { ); } -#[tokio::test] -async fn has_specification_version_v4() { - let provider = AnthropicProvider::new(AnthropicConfig::new("test-api-key")); - let files = provider.files(); - - assert_eq!(files.specification_version(), "v4"); -} - #[tokio::test] async fn has_correct_provider_name() { let provider = AnthropicProvider::new(AnthropicConfig::new("test-api-key")); diff --git a/aimux-providers/tests/baseten_test.rs b/aimux-providers/tests/baseten_test.rs index a1bb20359..3d7e0648c 100644 --- a/aimux-providers/tests/baseten_test.rs +++ b/aimux-providers/tests/baseten_test.rs @@ -23,6 +23,7 @@ use serde_json::{Value, json}; use serial_test::serial; +use std::sync::Arc; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -61,7 +62,7 @@ fn text_completion_body() -> Value { }) } -fn make_provider(server: &MockServer) -> Box { +fn make_provider(server: &MockServer) -> Arc { provider( "baseten", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs index 0a6a6bc7b..6ed471e58 100644 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ b/aimux-providers/tests/bedrock_remaining_test.rs @@ -32,7 +32,6 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::provider::Provider; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; @@ -446,13 +445,11 @@ fn provider_from_env_missing_credentials_errors() { clear_bedrock_env(); } -/// The `BedrockProvider` exposes its name and can vend language models. +/// The `BedrockProvider` can vend language models. #[test] -fn provider_name_and_language_model() { +fn provider_vends_language_model() { let provider = BedrockProvider::new(BedrockProviderConfig::with_bearer_token("tok", "us-east-1")); - assert_eq!(provider.name(), "amazon-bedrock"); - let model = provider.model("anthropic.claude-3-5-sonnet-20240620-v1:0"); assert_eq!( model.model_id(), diff --git a/aimux-providers/tests/body_overrides_test.rs b/aimux-providers/tests/body_overrides_test.rs index f8b392dcb..be7897d03 100644 --- a/aimux-providers/tests/body_overrides_test.rs +++ b/aimux-providers/tests/body_overrides_test.rs @@ -1,11 +1,10 @@ -//! Tests for RFC-0017 phase 1: `body_overrides` (JSON deep-merge) and -//! `max_retries` (per-call retry override). +//! Tests for provider-level `body_overrides` (RFC-0017) and `max_retries` +//! (per-call retry override). //! -//! `body_overrides` is a per-call JSON object deep-merged into the -//! provider-built request body after built-in vendor overrides. It lets users -//! inject/override arbitrary request fields (e.g. `enable_thinking`, -//! `thinking_budget`) without closure bridging — critical for aimux's -//! multi-language C ABI architecture. `null` values delete keys. +//! `body_overrides` is a JSON object configured on the provider and deep-merged +//! into the built request body right before sending (`apply_body_overrides`). +//! `null` values delete keys. There is no per-call override: `CallOptions` no +//! longer carries `body_overrides`. //! //! stage2-001 (RFC-0017 phase 2) additions: `max_tokens_key` branch and direct //! `reasoning` → `reasoning_effort` passthrough. The old "reasoning no-mapping" @@ -18,8 +17,8 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::types::{ReasoningEffort, Warning}; -use aimux_providers::openai::convert::build_request_body; use aimux_providers::openai::convert::build_request_body_with_warnings; +use aimux_providers::openai::convert::{apply_body_overrides, build_request_body}; use serde_json::{Value, json}; fn user_prompt() -> LanguageModelPrompt { @@ -30,12 +29,12 @@ fn user_prompt() -> LanguageModelPrompt { }] } -fn opts_with_overrides(prompt: LanguageModelPrompt, body_overrides: Value) -> CallOptions { - CallOptions { - prompt, - body_overrides: Some(body_overrides), - ..CallOptions::default() - } +/// The request body the OpenAI chat converter builds, with the provider-level +/// overrides applied the way `execute_generate` / `execute_stream` do. +fn body_with_overrides(options: &CallOptions, stream: bool, overrides: Value) -> Value { + let mut body = build_request_body("gpt-4o", options, stream).unwrap(); + apply_body_overrides(&mut body, Some(&overrides)); + body } // ── body_overrides: inject ─────────────────────────────────────────────────── @@ -43,8 +42,8 @@ fn opts_with_overrides(prompt: LanguageModelPrompt, body_overrides: Value) -> Ca /// A top-level key in body_overrides is injected into the request body. #[test] fn body_overrides_injects_new_field() { - let opts = opts_with_overrides(user_prompt(), json!({ "enable_thinking": false })); - let body = build_request_body("gpt-4o", &opts, false).unwrap(); + let opts = CallOptions::new(user_prompt()); + let body = body_with_overrides(&opts, false, json!({ "enable_thinking": false })); assert_eq!(body["enable_thinking"], json!(false)); assert_eq!(body["model"], json!("gpt-4o")); } @@ -52,8 +51,8 @@ fn body_overrides_injects_new_field() { /// body_overrides can inject nested objects. #[test] fn body_overrides_injects_nested_object() { - let opts = opts_with_overrides(user_prompt(), json!({ "thinking": { "type": "disabled" } })); - let body = build_request_body("gpt-4o", &opts, false).unwrap(); + let opts = CallOptions::new(user_prompt()); + let body = body_with_overrides(&opts, false, json!({ "thinking": { "type": "disabled" } })); assert_eq!(body["thinking"], json!({ "type": "disabled" })); } @@ -62,9 +61,9 @@ fn body_overrides_injects_nested_object() { /// body_overrides overwrites an existing field (e.g. temperature). #[test] fn body_overrides_overwrites_existing_field() { - let mut opts = opts_with_overrides(user_prompt(), json!({ "temperature": 0.1 })); + let mut opts = CallOptions::new(user_prompt()); opts.temperature = Some(0.9); // set by standard option - let body = build_request_body("gpt-4o", &opts, false).unwrap(); + let body = body_with_overrides(&opts, false, json!({ "temperature": 0.1 })); // body_overrides wins over standard option assert_eq!(body["temperature"], json!(0.1)); } @@ -78,11 +77,10 @@ fn body_overrides_overwrites_vendor_override_field() { let opts = CallOptions { prompt: user_prompt(), reasoning: Some(aimux_core::types::ReasoningEffort::None), - body_overrides: Some(json!({ "thinking": { "type": "enabled" } })), ..CallOptions::default() }; // DeepSeek profile 已回归 full()(特化退役),body_overrides 注入 thinking。 - let body = aimux_providers::openai::convert::build_request_body_with_warnings( + let mut body = aimux_providers::openai::convert::build_request_body_with_warnings( "deepseek-v4-flash", &opts, false, @@ -91,6 +89,10 @@ fn body_overrides_overwrites_vendor_override_field() { ) .unwrap() .body; + apply_body_overrides( + &mut body, + Some(&json!({ "thinking": { "type": "enabled" } })), + ); assert_eq!(body["thinking"], json!({ "type": "enabled" })); } @@ -102,12 +104,14 @@ fn body_overrides_deep_merges_nested_objects() { // The standard body has stream_options: { include_usage: true } for streams. // body_overrides adds another key to stream_options without clobbering // include_usage. - let opts = opts_with_overrides( - user_prompt(), - json!({ "stream_options": { "include_usage": false } }), + let opts = CallOptions::new(user_prompt()); + let body = body_with_overrides( + &opts, + true, + json!({ "stream_options": { "include_usage": false, "extra": 1 } }), ); - let body = build_request_body("gpt-4o", &opts, true).unwrap(); assert_eq!(body["stream_options"]["include_usage"], json!(false)); + assert_eq!(body["stream_options"]["extra"], json!(1)); } // ── body_overrides: null = delete ──────────────────────────────────────────── @@ -116,11 +120,13 @@ fn body_overrides_deep_merges_nested_objects() { /// request body. #[test] fn body_overrides_null_deletes_key() { - let opts = opts_with_overrides( - user_prompt(), + let mut opts = CallOptions::new(user_prompt()); + opts.temperature = Some(0.5); + let body = body_with_overrides( + &opts, + true, json!({ "stream_options": null, "temperature": null }), ); - let body = build_request_body("gpt-4o", &opts, true).unwrap(); assert!( body.get("stream_options").is_none(), "stream_options should be deleted by null" @@ -134,11 +140,12 @@ fn body_overrides_null_deletes_key() { /// null in a nested object deletes the nested key. #[test] fn body_overrides_null_deletes_nested_key() { - let opts = opts_with_overrides( - user_prompt(), + let opts = CallOptions::new(user_prompt()); + let body = body_with_overrides( + &opts, + true, json!({ "stream_options": { "include_usage": null } }), ); - let body = build_request_body("gpt-4o", &opts, true).unwrap(); // stream_options still exists but include_usage is gone assert!(body.get("stream_options").is_some()); assert!(body["stream_options"].get("include_usage").is_none()); @@ -146,14 +153,62 @@ fn body_overrides_null_deletes_nested_key() { // ── body_overrides: no overrides = unchanged ───────────────────────────────── -/// When body_overrides is None, the request body is identical to the standard -/// build (no merge happens). +/// With no overrides, the request body is identical to the standard build. #[test] fn no_body_overrides_leaves_body_unchanged() { let opts = CallOptions::new(user_prompt()); - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["model"], json!("gpt-4o")); - assert!(body.get("enable_thinking").is_none()); + let standard = build_request_body("gpt-4o", &opts, false).unwrap(); + let mut applied = standard.clone(); + apply_body_overrides(&mut applied, None); + assert_eq!(applied, standard); + assert!(applied.get("enable_thinking").is_none()); +} + +// ── body_overrides: provider-level, end to end ─────────────────────────────── + +/// The overrides configured on the provider reach the wire for both +/// `do_generate` and `do_stream`; `CallOptions` has no override of its own. +#[tokio::test] +async fn provider_body_overrides_reach_the_request() { + use aimux_core::language_model::LanguageModel; + use aimux_providers::openai::{OpenAIConfig, OpenAIProvider}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1711115037, + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "hi" }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + }))) + .mount(&server) + .await; + + let config = OpenAIConfig::new("test-key") + .with_base_url(server.uri()) + .with_body_overrides(json!({ "enable_thinking": false, "temperature": null })); + let model = OpenAIProvider::new(config).model("gpt-4o"); + let mut options = CallOptions::new(user_prompt()); + options.temperature = Some(0.7); + model.do_generate(&options).await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + let sent: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(sent["enable_thinking"], json!(false)); + assert!( + sent.get("temperature").is_none(), + "null deletes the standard field: {sent}" + ); } // ── max_retries ────────────────────────────────────────────────────────────── diff --git a/aimux-providers/tests/cerebras_test.rs b/aimux-providers/tests/cerebras_test.rs index 77188c825..74a252c4f 100644 --- a/aimux-providers/tests/cerebras_test.rs +++ b/aimux-providers/tests/cerebras_test.rs @@ -22,6 +22,7 @@ use serde_json::{Value, json}; use serial_test::serial; +use std::sync::Arc; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -60,7 +61,7 @@ fn text_completion_body() -> Value { }) } -fn make_provider(server: &MockServer) -> Box { +fn make_provider(server: &MockServer) -> Arc { provider( "cerebras", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/config_snapshot_test.rs b/aimux-providers/tests/config_snapshot_test.rs deleted file mode 100644 index 43e3c4023..000000000 --- a/aimux-providers/tests/config_snapshot_test.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! RFC-0023 `config_snapshot` / `api_key_source` coverage (findings C2/C3/C6). -//! -//! These are pure construction-time checks (no HTTP): build a config the way -//! real callers do (`from_env` / `new` / registry `provider()`), then assert -//! the recorded `ProviderRecord` carries the right provider identity and -//! api-key *source* (never the plaintext key). - -use aimux_core::language_model::LanguageModel; - -use aimux_providers::provider; -use aimux_providers::{ - AnthropicConfig, AnthropicProvider, AzureConfig, AzureModel, CodexConfig, CodexProvider, - CohereConfig, CohereProvider, GoogleConfig, GoogleProvider, MistralConfig, MistralProvider, - XAIConfig, XAIProvider, -}; - -// ── C2: OpenAI-compatible chat path surfaces the registry provider name ───── - -/// A registry-built deepseek model records `provider == "deepseek"` (not the -/// old hardcoded `"openai"`). The Chat path must use `config.provider`. -#[test] -fn registry_chat_snapshot_records_real_provider() { - let model = provider("deepseek", Some("sk-test".into()), "deepseek-chat", None) - .expect("deepseek constructs via registry"); - let snap = model.config_snapshot(); - assert_eq!( - snap.provider, "deepseek", - "snapshot must carry the registry name" - ); - assert_eq!(snap.model_id, "deepseek-chat"); - assert_eq!(model.provider(), "deepseek"); - // Registry `provider(name, None, ..)` reads the env var → env source. - assert!( - snap.api_key_source == "explicit" || snap.api_key_source.starts_with("env:"), - "api_key_source should be explicit (key given) or env:, got {}", - snap.api_key_source - ); -} - -/// Explicit key → `explicit`; no plaintext leaks into the snapshot. -#[test] -fn registry_chat_explicit_key_source() { - let model = provider("groq", Some("sk-test".into()), "llama-3.3-70b", None).unwrap(); - let snap = model.config_snapshot(); - assert_eq!(snap.api_key_source, "explicit"); - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("sk-test"), "plaintext key leaked: {json}"); -} - -// ── C3: native providers record env vs explicit api_key_source ────────────── - -/// Google `from_env` → `env:GOOGLE_GENERATIVE_AI_API_KEY`; `new` → `explicit`. -#[test] -fn google_api_key_source_env_vs_explicit() { - let explicit = GoogleConfig::new("g-explicit"); - let snap = GoogleProvider::new(explicit) - .model("gemini-2.0-flash") - .config_snapshot(); - assert_eq!(snap.provider, "google.generative-ai"); - assert_eq!(snap.api_key_source, "explicit"); - - // from_env requires the env var; set it so construction succeeds. - unsafe { std::env::set_var("GOOGLE_GENERATIVE_AI_API_KEY", "g-env") }; - let env_cfg = GoogleConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("GOOGLE_GENERATIVE_AI_API_KEY") }; - let snap = GoogleProvider::new(env_cfg) - .model("gemini-2.0-flash") - .config_snapshot(); - assert_eq!( - snap.api_key_source, "env:GOOGLE_GENERATIVE_AI_API_KEY", - "from_env must mark the env source" - ); - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("g-env"), "plaintext key leaked: {json}"); -} - -/// Anthropic `from_env` → `env:ANTHROPIC_API_KEY`; `new` → `explicit`. Also -/// covers C6: the snapshot's `provider_options` is a ProviderOptions-shaped -/// object carrying `headers`/`api_version`/`body_overrides`. -#[test] -fn anthropic_api_key_source_and_snapshot_shape() { - let explicit = AnthropicConfig::new("a-explicit"); - let snap = AnthropicProvider::new(explicit) - .model("claude-3-5-sonnet-20241022") - .config_snapshot(); - assert_eq!(snap.provider, "anthropic.messages"); - assert_eq!(snap.api_key_source, "explicit"); - - unsafe { std::env::set_var("ANTHROPIC_API_KEY", "a-env") }; - let env_cfg = AnthropicConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("ANTHROPIC_API_KEY") }; - let snap = AnthropicProvider::new(env_cfg) - .model("claude-3-5-sonnet-20241022") - .config_snapshot(); - assert_eq!(snap.api_key_source, "env:ANTHROPIC_API_KEY"); - - // C6: provider_options shape (not a bare headers map). - let opts = snap - .provider_options - .as_ref() - .expect("anthropic snapshot must carry provider_options"); - assert!(opts.get("headers").is_some(), "missing headers: {opts}"); - assert_eq!(opts["api_version"], "2023-06-01"); - assert!( - opts.get("max_retries").is_none(), - "retry is call-level and must not be recorded as provider config: {opts}" - ); - assert!( - opts.get("body_overrides").is_some(), - "missing body_overrides: {opts}" - ); - - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("a-env"), "plaintext key leaked: {json}"); -} - -/// Mistral `from_env` → `env:MISTRAL_API_KEY`. -#[test] -fn mistral_api_key_source_env() { - unsafe { std::env::set_var("MISTRAL_API_KEY", "m-env") }; - let cfg = MistralConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("MISTRAL_API_KEY") }; - let snap = MistralProvider::new(cfg) - .model("mistral-small-latest") - .config_snapshot(); - assert_eq!(snap.provider, "mistral"); - assert_eq!(snap.api_key_source, "env:MISTRAL_API_KEY"); -} - -/// Cohere `from_env` → `env:COHERE_API_KEY`. -#[test] -fn cohere_api_key_source_env() { - unsafe { std::env::set_var("COHERE_API_KEY", "c-env") }; - let cfg = CohereConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("COHERE_API_KEY") }; - let snap = CohereProvider::new(cfg) - .model("command-r-plus") - .config_snapshot(); - assert_eq!(snap.provider, "cohere"); - assert_eq!(snap.api_key_source, "env:COHERE_API_KEY"); -} - -/// Azure `from_env` → `env:AZURE_API_KEY`; explicit api-key → `explicit`. -#[test] -fn azure_api_key_source_env_vs_explicit() { - let cfg = AzureConfig::new() - .with_resource_name("my-resource") - .with_api_key("az-explicit"); - let snap = AzureModel::new("gpt-4o".into(), cfg).config_snapshot(); - assert_eq!(snap.provider, "azure"); - assert_eq!(snap.api_key_source, "explicit"); - - unsafe { - std::env::set_var("AZURE_API_KEY", "az-env"); - std::env::set_var("AZURE_RESOURCE_NAME", "res"); - } - let cfg = AzureConfig::from_env().expect("env var set"); - unsafe { - std::env::remove_var("AZURE_API_KEY"); - std::env::remove_var("AZURE_RESOURCE_NAME"); - } - let snap = AzureModel::new("gpt-4o".into(), cfg).config_snapshot(); - assert_eq!(snap.api_key_source, "env:AZURE_API_KEY"); - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("az-env"), "plaintext key leaked: {json}"); -} - -/// Codex `from_env` → `env:CODEX_API_KEY`; `new` → `explicit`. -#[test] -fn codex_api_key_source_env_vs_explicit() { - let snap = CodexProvider::new(CodexConfig::new("sk-test")) - .model("gpt-5.2-codex") - .config_snapshot(); - assert_eq!(snap.provider, "codex"); - assert_eq!(snap.api_key_source, "explicit"); - - unsafe { std::env::set_var("CODEX_API_KEY", "cx-env") }; - let cfg = CodexConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("CODEX_API_KEY") }; - let snap = CodexProvider::new(cfg) - .model("gpt-5.2-codex") - .config_snapshot(); - assert_eq!(snap.api_key_source, "env:CODEX_API_KEY"); -} - -/// xAI `from_env` → `env:XAI_API_KEY`; `new` → `explicit`. M2b: `model()` must -/// preserve the credential source instead of reconstructing `XAIConfig::new` -/// (which previously dropped `api_key_source` → snapshot wrongly said -/// "explicit" even for env-built providers). -#[test] -fn xai_api_key_source_env_vs_explicit() { - let snap = XAIProvider::new(XAIConfig::new("x-explicit")) - .model("grok-2") - .config_snapshot(); - assert_eq!(snap.provider, "xai.chat"); - assert_eq!(snap.api_key_source, "explicit"); - - unsafe { std::env::set_var("XAI_API_KEY", "x-env") }; - let cfg = XAIConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("XAI_API_KEY") }; - let snap = XAIProvider::new(cfg).model("grok-2").config_snapshot(); - assert_eq!( - snap.api_key_source, "env:XAI_API_KEY", - "model() must preserve the env credential source from from_env" - ); - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("x-env"), "plaintext key leaked: {json}"); -} diff --git a/aimux-providers/tests/conformance_test.rs b/aimux-providers/tests/conformance_test.rs index 098f2fc04..cf5139f50 100644 --- a/aimux-providers/tests/conformance_test.rs +++ b/aimux-providers/tests/conformance_test.rs @@ -13,6 +13,7 @@ mod common; use common::replay::mount_cassettes; use futures::StreamExt; +use std::sync::Arc; use wiremock::MockServer; use aimux_core::content::ContentPart; @@ -273,7 +274,7 @@ mod openai_conformance { mod deepseek_conformance { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { provider( "deepseek", Some("test-key".to_string()), @@ -364,7 +365,7 @@ mod xai_conformance { mod groq_conformance { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { // Cassettes record paths under /openai/v1/chat/completions, matching // Groq's default base URL https://api.groq.com/openai/v1. provider( @@ -457,7 +458,7 @@ mod mistral_conformance { mod perplexity_conformance { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { provider( "perplexity", Some("test-key".to_string()), @@ -618,7 +619,7 @@ mod openrouter_conformance { mod copilot_conformance { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { // Copilot's base URL has no `/v1` prefix; the OpenAIProvider appends // `/chat/completions` directly, matching the cassette path // `/chat/completions`. @@ -669,7 +670,7 @@ mod copilot_conformance { mod doubleword_conformance { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { // Doubleword's base URL includes `/v1`; the OpenAIProvider appends // `/chat/completions`, so we point at `/v1` to match the // cassette path `/v1/chat/completions`. @@ -880,7 +881,7 @@ mod bedrock_conformance { mod cerebras_conformance { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { // Cassettes record paths under /v1/chat/completions, matching Cerebras's // default base URL https://api.cerebras.ai/v1. provider( @@ -1018,7 +1019,7 @@ mod huggingface_conformance { mod zai_conformance { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { // Cassettes record paths under /api/paas/v4/chat/completions, matching // Zai's base URL https://api.z.ai/api/paas/v4. provider( @@ -1234,8 +1235,8 @@ macro_rules! thin_wrapper_conformance { mod $mod_name { use super::*; - fn make_provider(server: &MockServer) -> Box { - Box::new( + fn make_provider(server: &MockServer) -> Arc { + Arc::new( <$provider>::new(<$config>::new("test-key").with_base_url(format!( "{}{}", server.uri(), @@ -1252,7 +1253,7 @@ macro_rules! thin_wrapper_conformance { mod $mod_name { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { provider( $name, Some("test-key".to_string()), diff --git a/aimux-providers/tests/convert_golden_test.rs b/aimux-providers/tests/convert_golden_test.rs index b0d197ffa..a21c9c22c 100644 --- a/aimux-providers/tests/convert_golden_test.rs +++ b/aimux-providers/tests/convert_golden_test.rs @@ -66,7 +66,6 @@ fn openai_chat_golden() { tools: Some(vec![Tool::Function(weather_tool())]), tool_choice: ToolChoice::Auto, response_format: Some(json_schema_format()), - body_overrides: Some(json!({ "user": "override-me" })), ..CallOptions::default() }; @@ -114,14 +113,12 @@ fn openai_chat_golden() { } } ], - "tool_choice": "auto", - "user": "override-me" + "tool_choice": "auto" }), "openai chat body diverged: {}", result.body ); - // `temperature` is stripped for reasoning models with a warning; the - // body override `user` lands after built-in fields. + // `temperature` is stripped for reasoning models with a warning. assert_eq!(result.body.get("temperature"), None); assert_eq!( serde_json::to_value(&result.warnings).unwrap(), diff --git a/aimux-providers/tests/dataforseo_test.rs b/aimux-providers/tests/dataforseo_test.rs index b4517e30f..58efce20a 100644 --- a/aimux-providers/tests/dataforseo_test.rs +++ b/aimux-providers/tests/dataforseo_test.rs @@ -221,23 +221,24 @@ async fn status_401_maps_to_auth_error() { // -- Provider trait ---------------------------------------------------------- #[tokio::test] -async fn provider_name_is_dataforseo() { +async fn search_model_provider_is_dataforseo() { let config = DataforseoConfig::new(LOGIN, PASSWORD); let provider = DataforseoProvider::new(config); - assert_eq!(provider.name(), "dataforseo"); + assert_eq!(provider.search_model().provider(), "dataforseo"); } #[test] -fn language_model_returns_unsupported_error() { +fn language_model_returns_no_such_model() { let config = DataforseoConfig::new(LOGIN, PASSWORD); let provider = DataforseoProvider::new(config); match provider.language_model("dataforseo-search") { - Err(AiMuxError::UnsupportedFunctionality(msg)) => { - assert!( - msg.contains("provider 'dataforseo' does not provide language models"), - "unexpected message: {msg}" - ); + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, "dataforseo-search"); + assert_eq!(model_type, "languageModel"); } - _ => panic!("expected Unsupported error, got success or another error variant"), + _ => panic!("expected NoSuchModel error, got success or another error variant"), } } diff --git a/aimux-providers/tests/deepinfra_test.rs b/aimux-providers/tests/deepinfra_test.rs index abdbce70c..a821f8d62 100644 --- a/aimux-providers/tests/deepinfra_test.rs +++ b/aimux-providers/tests/deepinfra_test.rs @@ -21,6 +21,7 @@ use serde_json::{Value, json}; use serial_test::serial; +use std::sync::Arc; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -59,7 +60,7 @@ fn text_completion_body() -> Value { }) } -fn make_provider(server: &MockServer) -> Box { +fn make_provider(server: &MockServer) -> Arc { provider( "deepinfra", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/deepseek_chat_test.rs b/aimux-providers/tests/deepseek_chat_test.rs index 36729ff0b..60a2f268b 100644 --- a/aimux-providers/tests/deepseek_chat_test.rs +++ b/aimux-providers/tests/deepseek_chat_test.rs @@ -27,6 +27,7 @@ //! resulting request path is `/chat/completions`. use std::collections::HashMap; +use std::sync::Arc; use futures::StreamExt; use serde_json::{Value, json}; @@ -66,7 +67,7 @@ fn default_options(prompt: LanguageModelPrompt) -> CallOptions { } /// Build a DeepSeek provider whose base URL points at the mock server. -fn make_provider(server: &MockServer, model_id: &str) -> Box { +fn make_provider(server: &MockServer, model_id: &str) -> Arc { provider( "deepseek", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/deepseek_reasoning_test.rs b/aimux-providers/tests/deepseek_reasoning_test.rs index c51b19be9..a35a6d177 100644 --- a/aimux-providers/tests/deepseek_reasoning_test.rs +++ b/aimux-providers/tests/deepseek_reasoning_test.rs @@ -46,6 +46,7 @@ //! `openai_compatible_test.rs`. use std::collections::HashMap; +use std::sync::Arc; use futures::StreamExt; use serde_json::{Value, json}; @@ -82,7 +83,7 @@ fn default_options(prompt: LanguageModelPrompt) -> CallOptions { } /// Build a DeepSeek provider whose base URL points at the mock server. -fn make_provider(server: &MockServer) -> Box { +fn make_provider(server: &MockServer) -> Arc { provider( "deepseek", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/exa_ai_test.rs b/aimux-providers/tests/exa_ai_test.rs index 8c8a56e8b..39f929e66 100644 --- a/aimux-providers/tests/exa_ai_test.rs +++ b/aimux-providers/tests/exa_ai_test.rs @@ -14,9 +14,9 @@ fn make_provider(server: &MockServer) -> ExaAiProvider { } #[test] -fn provider_name_is_exa_ai() { +fn search_model_provider_is_exa_ai() { let provider = ExaAiProvider::new(ExaAiConfig::new("test-key")); - assert_eq!(provider.name(), "exa_ai"); + assert_eq!(provider.search_model().provider(), "exa_ai"); } #[test] diff --git a/aimux-providers/tests/firecrawl_test.rs b/aimux-providers/tests/firecrawl_test.rs index 894458b1b..dbc3143db 100644 --- a/aimux-providers/tests/firecrawl_test.rs +++ b/aimux-providers/tests/firecrawl_test.rs @@ -14,9 +14,9 @@ fn make_provider(server: &MockServer) -> FirecrawlProvider { } #[test] -fn provider_name_is_firecrawl() { +fn search_model_provider_is_firecrawl() { let provider = FirecrawlProvider::new(FirecrawlConfig::new("test-key")); - assert_eq!(provider.name(), "firecrawl"); + assert_eq!(provider.search_model().provider(), "firecrawl"); } #[test] diff --git a/aimux-providers/tests/fireworks_test.rs b/aimux-providers/tests/fireworks_test.rs index f927626f1..9626fada0 100644 --- a/aimux-providers/tests/fireworks_test.rs +++ b/aimux-providers/tests/fireworks_test.rs @@ -25,6 +25,7 @@ use serde_json::{Value, json}; use serial_test::serial; +use std::sync::Arc; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -63,7 +64,7 @@ fn text_completion_body() -> Value { }) } -fn make_provider(server: &MockServer) -> Box { +fn make_provider(server: &MockServer) -> Arc { provider( "fireworks", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/google_files_test.rs b/aimux-providers/tests/google_files_test.rs index a7fb306ac..870f10ced 100644 --- a/aimux-providers/tests/google_files_test.rs +++ b/aimux-providers/tests/google_files_test.rs @@ -78,11 +78,10 @@ async fn mount_success_mocks(server: &MockServer) { // -- constructor tests ------------------------------------------------------- #[tokio::test] -async fn should_expose_correct_provider_and_specification_version() { +async fn should_expose_correct_provider() { let provider = GoogleProvider::new(GoogleConfig::new("test-api-key")); let files = provider.files(); assert_eq!(files.provider(), "google.generative-ai"); - assert_eq!(files.specification_version(), "v4"); } // -- upload initiation tests ------------------------------------------------- diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs index e269d81e6..da1cc0ef1 100644 --- a/aimux-providers/tests/google_model_test.rs +++ b/aimux-providers/tests/google_model_test.rs @@ -63,7 +63,6 @@ fn options_with_tools(prompt: LanguageModelPrompt, tools: Vec) -> headers: None, provider_options: None, reasoning: None, - body_overrides: None, max_retries: None, timeout: None, abort_signal: None, diff --git a/aimux-providers/tests/google_pse_test.rs b/aimux-providers/tests/google_pse_test.rs index 79e01fe07..782883919 100644 --- a/aimux-providers/tests/google_pse_test.rs +++ b/aimux-providers/tests/google_pse_test.rs @@ -250,24 +250,25 @@ async fn status_403_maps_to_provider_error() { // -- Provider trait ---------------------------------------------------------- #[tokio::test] -async fn provider_name_is_google_pse() { +async fn search_model_provider_is_google_pse() { let config = GooglePseConfig::new(API_KEY).with_cx(CX); let provider = GooglePseProvider::new(config); - assert_eq!(provider.name(), "google_pse"); + assert_eq!(provider.search_model().provider(), "google_pse"); } #[test] -fn language_model_returns_unsupported_error() { +fn language_model_returns_no_such_model() { let config = GooglePseConfig::new(API_KEY).with_cx(CX); let provider = GooglePseProvider::new(config); match provider.language_model("google-pse-search") { - Err(AiMuxError::UnsupportedFunctionality(msg)) => { - assert!( - msg.contains("provider 'google_pse' does not provide language models"), - "unexpected message: {msg}" - ); + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, "google-pse-search"); + assert_eq!(model_type, "languageModel"); } - _ => panic!("expected Unsupported error, got success or another error variant"), + _ => panic!("expected NoSuchModel error, got success or another error variant"), } } diff --git a/aimux-providers/tests/google_remaining_test.rs b/aimux-providers/tests/google_remaining_test.rs index 30023ac97..0e3ece265 100644 --- a/aimux-providers/tests/google_remaining_test.rs +++ b/aimux-providers/tests/google_remaining_test.rs @@ -1322,11 +1322,13 @@ mod provider_config_tests { } #[tokio::test] - async fn provider_name_is_google() { - use aimux_core::provider::Provider; + async fn model_provider_is_google() { let config = GoogleConfig::new("test-api-key"); let provider = GoogleProvider::new(config); - assert_eq!(provider.name(), "google"); + assert_eq!( + provider.model("gemini-2.0-flash").provider(), + "google.generative-ai" + ); } #[tokio::test] @@ -1430,7 +1432,6 @@ mod provider_config_tests { #[tokio::test] async fn language_model_trait_method_returns_boxed_model() { use aimux_core::provider::Provider; - let config = GoogleConfig::new("test-api-key"); let provider = GoogleProvider::new(config); let model = provider.language_model("gemini-2.0-flash").unwrap(); diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 276f3294b..6486151cb 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -20,6 +20,7 @@ use futures::StreamExt; use serde_json::{Value, json}; +use std::sync::Arc; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -52,7 +53,7 @@ fn default_options(prompt: LanguageModelPrompt) -> CallOptions { } /// Build a provider pointed at the mock server. -fn make_provider(server: &MockServer) -> Box { +fn make_provider(server: &MockServer) -> Arc { provider( "groq", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/jina_ai_test.rs b/aimux-providers/tests/jina_ai_test.rs index 7729fd34a..feef8eaff 100644 --- a/aimux-providers/tests/jina_ai_test.rs +++ b/aimux-providers/tests/jina_ai_test.rs @@ -306,23 +306,24 @@ async fn status_401_maps_to_auth_error() { // -- Provider trait ---------------------------------------------------------- #[tokio::test] -async fn provider_name_is_jina_ai() { +async fn reranking_model_provider_is_jina_ai() { let config = JinaAiConfig::new(API_KEY); let provider = JinaAiProvider::new(config); - assert_eq!(provider.name(), "jina_ai"); + assert_eq!(provider.reranking_model(MODEL).provider(), "jina_ai"); } #[test] -fn language_model_returns_unsupported_error() { +fn language_model_returns_no_such_model() { let config = JinaAiConfig::new(API_KEY); let provider = JinaAiProvider::new(config); match provider.language_model(MODEL) { - Err(AiMuxError::UnsupportedFunctionality(msg)) => { - assert!( - msg.contains("provider 'jina_ai' does not provide language models"), - "unexpected message: {msg}" - ); + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, MODEL); + assert_eq!(model_type, "languageModel"); } - _ => panic!("expected Unsupported error, got success or another error variant"), + _ => panic!("expected NoSuchModel error, got success or another error variant"), } } diff --git a/aimux-providers/tests/linkup_test.rs b/aimux-providers/tests/linkup_test.rs index 898b2178e..7426b3185 100644 --- a/aimux-providers/tests/linkup_test.rs +++ b/aimux-providers/tests/linkup_test.rs @@ -261,24 +261,25 @@ async fn status_403_maps_to_provider_error() { // -- Provider trait ---------------------------------------------------------- #[tokio::test] -async fn provider_name_is_linkup() { +async fn search_model_provider_is_linkup() { let config = LinkupConfig::new(API_KEY); let provider = LinkupProvider::new(config); - assert_eq!(provider.name(), "linkup"); + assert_eq!(provider.search_model().provider(), "linkup"); } #[test] -fn language_model_returns_unsupported_error() { +fn language_model_returns_no_such_model() { let config = LinkupConfig::new(API_KEY); let provider = LinkupProvider::new(config); match provider.language_model("linkup-search") { - Err(AiMuxError::UnsupportedFunctionality(msg)) => { - assert!( - msg.contains("provider 'linkup' does not provide language models"), - "unexpected message: {msg}" - ); + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, "linkup-search"); + assert_eq!(model_type, "languageModel"); } - _ => panic!("expected Unsupported error, got success or another error variant"), + _ => panic!("expected NoSuchModel error, got success or another error variant"), } } diff --git a/aimux-providers/tests/list_models_test.rs b/aimux-providers/tests/list_models_test.rs index 2dffd5c1d..a0a717233 100644 --- a/aimux-providers/tests/list_models_test.rs +++ b/aimux-providers/tests/list_models_test.rs @@ -1,4 +1,4 @@ -//! Integration tests for `Provider::list_models` (RFC-0027). +//! Integration tests for `ProviderDiscovery::list_models` (RFC-0027). //! //! `list_models` returns `RuntimeModel` (provider's official data only). //! Community catalogue (`get_model_specs`) is tested separately — the two are @@ -14,10 +14,10 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::model_catalogue::RuntimeModel; -use aimux_core::provider::Provider; +use aimux_core::provider::ProviderDiscovery; use aimux_providers::catalogue; use aimux_providers::openai::{OpenAIConfig, OpenAIProvider}; -use aimux_providers::{ProviderOptions, provider_handle}; +use aimux_providers::{ProviderOptions, provider_discovery, provider_handle}; async fn mount_cassette_file(server: &MockServer, cassette_path: &Path) -> String { let text = std::fs::read_to_string(cassette_path) @@ -71,7 +71,7 @@ async fn openai_provider_list_models() { #[tokio::test] #[serial] -async fn deepseek_provider_list_models_via_handle() { +async fn deepseek_provider_list_models_via_discovery_handle() { let server = MockServer::start().await; let cassette = Path::new("tests/cassettes/deepseek/list_models_smoke.json"); let recorded_path = mount_cassette_file(&server, cassette).await; @@ -80,7 +80,7 @@ async fn deepseek_provider_list_models_via_handle() { base_url: Some(base_url), ..Default::default() }; - let handle = provider_handle("deepseek", Some("test-key".into()), Some(opts)).unwrap(); + let handle = provider_discovery("deepseek", Some("test-key".into()), Some(opts)).unwrap(); let models = handle.list_models().await.unwrap(); assert_eq!(models.len(), 2); assert!(models.iter().any(|m| m.id == "deepseek-v4-flash")); @@ -98,8 +98,10 @@ async fn provider_handle_then_language_model() { base_url: Some(base_url), ..Default::default() }; + let discovery = + provider_discovery("deepseek", Some("test-key".into()), Some(opts.clone())).unwrap(); let handle = provider_handle("deepseek", Some("test-key".into()), Some(opts)).unwrap(); - let models = handle.list_models().await.unwrap(); + let models = discovery.list_models().await.unwrap(); let first_id = models[0].id.clone(); let _model = handle.language_model(&first_id).unwrap(); } @@ -225,28 +227,15 @@ fn provider_handle_unknown_name() { } } -#[tokio::test] -async fn unsupported_provider_returns_unsupported() { - struct StubProvider; - impl Provider for StubProvider { - fn name(&self) -> &str { - "stub" - } - fn language_model( - &self, - _model_id: &str, - ) -> Result, aimux_core::AiMuxError> - { - Err(aimux_core::AiMuxError::UnsupportedFunctionality( - "none".into(), - )) +#[test] +fn discovery_for_unknown_provider_is_no_such_provider() { + // `Arc` is not `Debug`, so match instead of `unwrap_err`. + match provider_discovery("no-such-provider", Some("k".into()), None) { + Err(aimux_core::AiMuxError::NoSuchProvider { provider_id }) => { + assert_eq!(provider_id, "no-such-provider"); } + _ => panic!("expected NoSuchProvider"), } - let err = StubProvider.list_models().await.unwrap_err(); - assert!(matches!( - err, - aimux_core::AiMuxError::UnsupportedFunctionality(_) - )); } #[test] diff --git a/aimux-providers/tests/moonshotai_test.rs b/aimux-providers/tests/moonshotai_test.rs index 8b6bbb8ad..7c5ee5ba4 100644 --- a/aimux-providers/tests/moonshotai_test.rs +++ b/aimux-providers/tests/moonshotai_test.rs @@ -20,6 +20,7 @@ use futures::StreamExt; use serde_json::{Value, json}; use serial_test::serial; +use std::sync::Arc; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -89,7 +90,7 @@ async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec Box { +fn make_provider(server: &MockServer) -> Arc { provider( "moonshotai", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/open_responses_test.rs b/aimux-providers/tests/open_responses_test.rs index f2821d69a..b0f060313 100644 --- a/aimux-providers/tests/open_responses_test.rs +++ b/aimux-providers/tests/open_responses_test.rs @@ -2145,89 +2145,3 @@ mod do_stream_tests { let _parts = collect_stream(result).await; } } - -// ============================================================================ -// config_snapshot / api_key_source (M2b) -// ============================================================================ - -#[cfg(test)] -mod config_snapshot_tests { - use super::*; - use aimux_core::language_model::LanguageModel; - - /// No `headers` closure → no auth → `api_key_source == "none"` (e.g. a local - /// LM Studio server). - #[test] - fn no_headers_means_none_source() { - let provider = OpenResponsesProvider::new(make_config_dummy()); - let snap = provider.model("gemma-7b-it").config_snapshot(); - assert_eq!(snap.provider, "lmstudio"); - assert_eq!(snap.api_key_source, "none"); - } - - /// A `headers` closure that carries an `Authorization` header → - /// `api_key_source == "explicit"` (auth detected from the closure; the - /// secret value is never serialized). - #[test] - fn headers_with_auth_means_explicit_source() { - let config = - OpenResponsesConfig::new("lmstudio", "lmstudio", "https://example/v1/responses") - .with_headers(|| { - let mut h = HashMap::new(); - h.insert( - "Authorization".to_string(), - "Bearer super-secret".to_string(), - ); - h - }); - let snap = OpenResponsesProvider::new(config) - .model("gemma-7b-it") - .config_snapshot(); - assert_eq!(snap.api_key_source, "explicit"); - let json = serde_json::to_string(&snap).unwrap(); - assert!( - !json.contains("super-secret"), - "plaintext secret leaked: {json}" - ); - } - - /// An explicit `with_api_key_source` marker overrides closure inference - /// (lets a caller record an `env:VAR` source precisely). - #[test] - fn explicit_source_overrides_closure_inference() { - let config = - OpenResponsesConfig::new("lmstudio", "lmstudio", "https://example/v1/responses") - .with_headers(|| { - let mut h = HashMap::new(); - h.insert("Authorization".to_string(), "Bearer k".to_string()); - h - }) - .with_api_key_source(Some("env:OPEN_RESPONSES_API_KEY")); - let snap = OpenResponsesProvider::new(config) - .model("gemma-7b-it") - .config_snapshot(); - assert_eq!(snap.api_key_source, "env:OPEN_RESPONSES_API_KEY"); - } - - /// A `headers` closure carrying only non-auth custom headers → `none` - /// (auth detection is key-name based, not "closure present"). - #[test] - fn headers_without_auth_means_none_source() { - let config = - OpenResponsesConfig::new("lmstudio", "lmstudio", "https://example/v1/responses") - .with_headers(|| { - let mut h = HashMap::new(); - h.insert("X-Custom".to_string(), "value".to_string()); - h - }); - let snap = OpenResponsesProvider::new(config) - .model("gemma-7b-it") - .config_snapshot(); - assert_eq!(snap.api_key_source, "none"); - } - - /// Minimal config without a mock server (snapshot needs no HTTP). - fn make_config_dummy() -> OpenResponsesConfig { - OpenResponsesConfig::new("lmstudio", "lmstudio", "https://example/v1/responses") - } -} diff --git a/aimux-providers/tests/openai_compatible_test.rs b/aimux-providers/tests/openai_compatible_test.rs index 8e49e65c1..708ea0c7c 100644 --- a/aimux-providers/tests/openai_compatible_test.rs +++ b/aimux-providers/tests/openai_compatible_test.rs @@ -17,6 +17,7 @@ use futures::StreamExt; use serde_json::{Value, json}; +use std::sync::Arc; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -146,9 +147,9 @@ macro_rules! openai_compatible_tests { mod $mod_name { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { let config = <$config>::new("test-api-key").with_base_url(server.uri()); - Box::new(<$provider>::new(config).model($model_id)) + Arc::new(<$provider>::new(config).model($model_id)) } mod $mod_name { use super::*; @@ -315,7 +316,7 @@ macro_rules! openai_compatible_tests { mod $mod_name { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { provider( $provider_name, Some("test-api-key".to_string()), @@ -563,9 +564,9 @@ macro_rules! openai_compatible_tool_tests { mod $mod_name { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { let config = <$config>::new("test-api-key").with_base_url(server.uri()); - Box::new(<$provider>::new(config).model($model_id)) + Arc::new(<$provider>::new(config).model($model_id)) } mod $mod_name { use super::*; @@ -651,7 +652,7 @@ macro_rules! openai_compatible_tool_tests { mod $mod_name { use super::*; - fn make_provider(server: &MockServer) -> Box { + fn make_provider(server: &MockServer) -> Arc { provider( $provider_name, Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/openai_convert_extended_test.rs b/aimux-providers/tests/openai_convert_extended_test.rs index 6e6f71d17..115908187 100644 --- a/aimux-providers/tests/openai_convert_extended_test.rs +++ b/aimux-providers/tests/openai_convert_extended_test.rs @@ -60,7 +60,6 @@ fn default_opts(p: LanguageModelPrompt) -> CallOptions { headers: None, provider_options: None, reasoning: None, - body_overrides: None, max_retries: None, timeout: None, abort_signal: None, diff --git a/aimux-providers/tests/openai_files_test.rs b/aimux-providers/tests/openai_files_test.rs index 8edc7913c..eae642582 100644 --- a/aimux-providers/tests/openai_files_test.rs +++ b/aimux-providers/tests/openai_files_test.rs @@ -272,11 +272,10 @@ async fn should_handle_base64_string_data() { } #[tokio::test] -async fn should_set_specification_version_and_provider() { +async fn should_set_provider() { let provider = OpenAIProvider::new(OpenAIConfig::new("test-api-key")); let files = provider.files(); - assert_eq!(files.specification_version(), "v4"); assert_eq!(files.provider(), "openai.files"); } diff --git a/aimux-providers/tests/openai_model_extended_test.rs b/aimux-providers/tests/openai_model_extended_test.rs index 560f93ccc..05156c6fa 100644 --- a/aimux-providers/tests/openai_model_extended_test.rs +++ b/aimux-providers/tests/openai_model_extended_test.rs @@ -43,7 +43,6 @@ fn default_opts(p: LanguageModelPrompt) -> CallOptions { headers: None, provider_options: None, reasoning: None, - body_overrides: None, max_retries: None, timeout: None, abort_signal: None, diff --git a/aimux-providers/tests/openai_model_test.rs b/aimux-providers/tests/openai_model_test.rs index 15b2d2137..9dac61d80 100644 --- a/aimux-providers/tests/openai_model_test.rs +++ b/aimux-providers/tests/openai_model_test.rs @@ -73,7 +73,6 @@ fn options_with_tools(prompt: LanguageModelPrompt, tools: Vec) -> headers: None, provider_options: None, reasoning: None, - body_overrides: None, max_retries: None, timeout: None, abort_signal: None, diff --git a/aimux-providers/tests/openai_provider_test.rs b/aimux-providers/tests/openai_provider_test.rs index 4dcfe790b..75d97a653 100644 --- a/aimux-providers/tests/openai_provider_test.rs +++ b/aimux-providers/tests/openai_provider_test.rs @@ -58,10 +58,10 @@ mod provider_config { /// TS: `createOpenAI()` provider name is "openai". #[test] - fn provider_name_is_openai() { + fn model_provider_is_openai() { let config = OpenAIConfig::new("test-key"); let provider = OpenAIProvider::new(config); - assert_eq!(provider.name(), "openai"); + assert_eq!(provider.model("gpt-4o").provider(), "openai"); } /// TS: default base URL is `https://api.openai.com/v1`. diff --git a/aimux-providers/tests/openrouter_test.rs b/aimux-providers/tests/openrouter_test.rs index 320979af6..8678325e2 100644 --- a/aimux-providers/tests/openrouter_test.rs +++ b/aimux-providers/tests/openrouter_test.rs @@ -1,4 +1,4 @@ -//! Provider-specific tests for the OpenRouter provider. +//! Provider-specific tests for the OpenRouter provider. //! //! OpenRouter is a thin OpenAI-compatible wrapper over [`OpenAIProvider`]. The //! behaviours verified here are the ones the wrapper is responsible for: @@ -129,10 +129,13 @@ fn make_provider(server: &MockServer) -> OpenRouterProvider { /// `createOpenRouter()` produces a provider whose name is "openrouter". #[test] -fn provider_name_is_openrouter() { +fn model_provider_is_openrouter() { let config = OpenRouterConfig::new("test-key"); let provider = OpenRouterProvider::new(config); - assert_eq!(provider.name(), "openrouter"); + assert_eq!( + provider.model("openai/gpt-4o-mini").provider(), + "openrouter" + ); } /// Custom API key is sent in the `Authorization: Bearer` header. diff --git a/aimux-providers/tests/parallel_ai_test.rs b/aimux-providers/tests/parallel_ai_test.rs index 82ca6261a..b6f505ffb 100644 --- a/aimux-providers/tests/parallel_ai_test.rs +++ b/aimux-providers/tests/parallel_ai_test.rs @@ -193,24 +193,25 @@ async fn status_403_maps_to_provider_error() { // -- Provider trait ---------------------------------------------------------- #[tokio::test] -async fn provider_name_is_parallel_ai() { +async fn search_model_provider_is_parallel_ai() { let config = ParallelAiConfig::new(API_KEY); let provider = ParallelAiProvider::new(config); - assert_eq!(provider.name(), "parallel_ai"); + assert_eq!(provider.search_model().provider(), "parallel_ai"); } #[test] -fn language_model_returns_unsupported_error() { +fn language_model_returns_no_such_model() { let config = ParallelAiConfig::new(API_KEY); let provider = ParallelAiProvider::new(config); match provider.language_model("parallel-search") { - Err(AiMuxError::UnsupportedFunctionality(msg)) => { - assert!( - msg.contains("provider 'parallel_ai' does not provide language models"), - "unexpected message: {msg}" - ); + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, "parallel-search"); + assert_eq!(model_type, "languageModel"); } - _ => panic!("expected Unsupported error, got success or another error variant"), + _ => panic!("expected NoSuchModel error, got success or another error variant"), } } diff --git a/aimux-providers/tests/perplexity_test.rs b/aimux-providers/tests/perplexity_test.rs index 4dd6ac16c..e0ce89254 100644 --- a/aimux-providers/tests/perplexity_test.rs +++ b/aimux-providers/tests/perplexity_test.rs @@ -23,6 +23,7 @@ use futures::StreamExt; use serde_json::{Value, json}; use serial_test::serial; +use std::sync::Arc; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -92,7 +93,7 @@ async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec Box { +fn make_provider(server: &MockServer) -> Arc { provider( "perplexity", Some("test-api-key".to_string()), diff --git a/aimux-providers/tests/provider_trait_test.rs b/aimux-providers/tests/provider_trait_test.rs new file mode 100644 index 000000000..3ab3e1341 --- /dev/null +++ b/aimux-providers/tests/provider_trait_test.rs @@ -0,0 +1,115 @@ +//! `Provider` trait wiring for the vendors' `impl Provider` blocks (RFC-0036 A1). +//! +//! Every vendor answers the three required constructors with a model or with +//! `NoSuchModel { model_type }`, and the optional ones with `Some`/`None` +//! according to what its inherent constructors offer. Checked through +//! `Arc` so the trait object is what is exercised. + +use std::sync::Arc; + +use aimux_core::AiMuxError; +use aimux_core::provider::{Provider, ProviderDiscovery}; +use aimux_providers::{ + GoogleConfig, GoogleProvider, OpenAIConfig, OpenAIProvider, SerperConfig, SerperProvider, + VllmConfig, VllmProvider, VoyageConfig, VoyageProvider, provider_discovery, provider_handle, +}; + +/// Unwrap the `NoSuchModel` model type of a failed `Arc` lookup +/// (`Arc` is not `Debug`, so `unwrap_err` is unavailable). +fn no_such_model_type(result: Result, AiMuxError>, id: &str) -> String { + match result { + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, id); + model_type + } + Err(other) => panic!("expected NoSuchModel, got {other}"), + Ok(_) => panic!("expected NoSuchModel, got a model"), + } +} + +#[test] +fn openai_offers_every_modality_it_has_constructors_for() { + let p: Arc = Arc::new(OpenAIProvider::new(OpenAIConfig::new("k"))); + assert_eq!(p.language_model("gpt-4o").unwrap().model_id(), "gpt-4o"); + assert!(p.embedding_model("text-embedding-3-small").is_ok()); + assert!(p.image_model("gpt-image-1").is_ok()); + assert!(p.speech_model("tts-1").unwrap().is_ok()); + assert!(p.transcription_model("whisper-1").unwrap().is_ok()); + assert!(p.files().is_some()); + assert!(p.reranking_model("r").is_none()); + assert!(p.video_model("v").is_none()); + assert!(p.search_model("s").is_none()); +} + +#[test] +fn google_offers_video_and_files() { + let p: Arc = Arc::new(GoogleProvider::new(GoogleConfig::new("k"))); + assert!(p.language_model("gemini-2.0-flash").is_ok()); + assert!(p.embedding_model("text-embedding-004").is_ok()); + assert!(p.image_model("imagen-3.0-generate-002").is_ok()); + assert!(p.video_model("veo-2.0-generate-001").unwrap().is_ok()); + assert!(p.files().is_some()); + assert!(p.speech_model("s").is_none()); +} + +#[test] +fn embedding_only_vendor_rejects_language_and_image_models() { + let p: Arc = Arc::new(VoyageProvider::new(VoyageConfig::new("k"))); + assert_eq!( + no_such_model_type(p.language_model("m"), "m"), + "languageModel" + ); + assert_eq!(no_such_model_type(p.image_model("m"), "m"), "imageModel"); + assert!(p.embedding_model("voyage-3").is_ok()); + assert!(p.reranking_model("rerank-2.5").unwrap().is_ok()); +} + +#[test] +fn language_only_wrapper_rejects_embedding_and_image_models() { + // `VllmProvider` is generated by `impl_single_modality_provider!`. + let p: Arc = Arc::new(VllmProvider::new(VllmConfig::new("k"))); + assert_eq!(p.language_model("llama").unwrap().model_id(), "llama"); + assert_eq!( + no_such_model_type(p.embedding_model("e"), "e"), + "embeddingModel" + ); + assert_eq!(no_such_model_type(p.image_model("i"), "i"), "imageModel"); + assert!(p.files().is_none()); +} + +#[test] +fn search_only_vendor_serves_search_and_nothing_else() { + // `SerperProvider` is generated by `impl_single_modality_provider!` with an + // optional (non-required) modality. + let p: Arc = Arc::new(SerperProvider::new(SerperConfig::new("k"))); + let search = p.search_model("ignored").unwrap().unwrap(); + assert_eq!(search.provider(), "serper"); + assert_eq!( + no_such_model_type(p.language_model("m"), "m"), + "languageModel" + ); + assert_eq!( + no_such_model_type(p.embedding_model("m"), "m"), + "embeddingModel" + ); + assert_eq!(no_such_model_type(p.image_model("m"), "m"), "imageModel"); + assert!(p.speech_model("m").is_none()); + assert!(p.video_model("m").is_none()); +} + +#[test] +fn registry_handles_split_models_from_discovery() { + let handle = provider_handle("groq", Some("k".into()), None).unwrap(); + assert_eq!( + handle.language_model("llama-3.3-70b").unwrap().provider(), + "groq" + ); + // The registry builds an OpenAI-compatible provider: it also serves the + // other required modalities through the shared OpenAI implementation. + assert!(handle.embedding_model("e").is_ok()); + let _discovery: Arc = + provider_discovery("groq", Some("k".into()), None).unwrap(); +} diff --git a/aimux-providers/tests/reasoning_map_test.rs b/aimux-providers/tests/reasoning_map_test.rs index 68ffcb0c7..24aa9f230 100644 --- a/aimux-providers/tests/reasoning_map_test.rs +++ b/aimux-providers/tests/reasoning_map_test.rs @@ -3,9 +3,10 @@ //! 设计来源:[stage2-reasoning-map.md](../../docs/plan/analysis/stage2-reasoning-map.md) §4 I4-I5、§5。 //! //! 覆盖: -//! - **I4**:退役后 DeepSeek 请求体不含 `thinking` 注入(除非用户 bodyOverrides 注入) -//! - **I5**:用户 `body_overrides: { thinking: { type: 'disabled' } }` → 请求体含之 -//! (阶段 1 能力回归) +//! - **I4**:退役后 DeepSeek 请求体不含 `thinking` 注入(除非 provider 级 +//! body_overrides 注入) +//! - **I5**:provider 级 `body_overrides: { thinking: { type: 'disabled' } }` → +//! 请求体含之(阶段 1 能力回归;调用级覆盖已删除) //! - **max_tokens_key 矩阵**:8 家接线(stepfun/siliconflow/sarvam/reka_ai/publicai/ //! perplexity → `"max_tokens"`;groq/heroku → `"max_completion_tokens"`)× //! 推理/非推理两分支。profile 取自注册表 `provider_registry_entry(name)` @@ -20,7 +21,7 @@ use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::types::{ReasoningEffort, Warning}; use aimux_providers::openai::OpenAICompatProfile; -use aimux_providers::openai::convert::build_request_body_with_warnings; +use aimux_providers::openai::convert::{apply_body_overrides, build_request_body_with_warnings}; use aimux_providers::provider_registry_entry; use serde_json::json; @@ -94,20 +95,19 @@ fn i4_deepseek_no_thinking_when_reasoning_unset() { } // ════════════════════════════════════════════════════════════════════════════ -// I5: 用户 body_overrides 注入 thinking → 请求体含之(阶段 1 能力回归) +// I5: provider 级 body_overrides 注入 thinking → 请求体含之(阶段 1 能力回归) // ════════════════════════════════════════════════════════════════════════════ -/// I5: 用户 `body_overrides: { thinking: { type: 'disabled' } }` 原样进入请求体。 +/// I5: provider 级 `body_overrides: { thinking: { type: 'disabled' } }` 原样进入请求体。 /// 关思考的语义完全由用户定义(退役后不再由 reasoning:'none' 自动注入)。 #[test] fn i5_body_overrides_injects_thinking_disabled() { let opts = CallOptions { prompt: user_prompt(), reasoning: Some(ReasoningEffort::None), - body_overrides: Some(json!({ "thinking": { "type": "disabled" } })), ..CallOptions::default() }; - let result = build_request_body_with_warnings( + let mut result = build_request_body_with_warnings( "deepseek-reasoner", &opts, false, @@ -115,20 +115,23 @@ fn i5_body_overrides_injects_thinking_disabled() { &OpenAICompatProfile::deepseek(), ) .unwrap(); + apply_body_overrides( + &mut result.body, + Some(&json!({ "thinking": { "type": "disabled" } })), + ); assert_eq!(result.body["thinking"], json!({ "type": "disabled" })); assert_eq!(result.body["reasoning_effort"], json!("none")); } -/// I5 补充: 用户注入 `thinking: { type: 'enabled' }` 同样原样进入请求体 -/// (开思考回归——退役前由特化注入,现由用户定义)。 +/// I5 补充: 注入 `thinking: { type: 'enabled' }` 同样原样进入请求体 +/// (开思考回归——退役前由特化注入,现由 provider 级覆盖定义)。 #[test] fn i5_body_overrides_injects_thinking_enabled() { let opts = CallOptions { prompt: user_prompt(), - body_overrides: Some(json!({ "thinking": { "type": "enabled" } })), ..CallOptions::default() }; - let result = build_request_body_with_warnings( + let mut result = build_request_body_with_warnings( "deepseek-reasoner", &opts, false, @@ -136,6 +139,10 @@ fn i5_body_overrides_injects_thinking_enabled() { &OpenAICompatProfile::deepseek(), ) .unwrap(); + apply_body_overrides( + &mut result.body, + Some(&json!({ "thinking": { "type": "enabled" } })), + ); assert_eq!(result.body["thinking"], json!({ "type": "enabled" })); } diff --git a/aimux-providers/tests/recraft_test.rs b/aimux-providers/tests/recraft_test.rs index ad3d2705f..68e213a99 100644 --- a/aimux-providers/tests/recraft_test.rs +++ b/aimux-providers/tests/recraft_test.rs @@ -238,21 +238,21 @@ async fn status_500_maps_to_provider_error() { // ════════════════════════════════════════════════════════════════════════════ #[tokio::test] -async fn provider_name_is_recraft() { +async fn image_model_provider_is_recraft() { let server = MockServer::start().await; let config = RecraftConfig::new(API_KEY).with_base_url(server.uri()); let provider = RecraftProvider::new(config); - assert_eq!(provider.name(), "recraft"); + assert_eq!(provider.image("recraftv3").provider(), "recraft"); } #[test] -fn language_model_returns_unsupported_error() { +fn language_model_returns_no_such_model() { let config = RecraftConfig::new(API_KEY); let provider = RecraftProvider::new(config); let result = provider.language_model("recraftv3"); assert!( - matches!(result, Err(AiMuxError::UnsupportedFunctionality(_))), - "expected Unsupported error" + matches!(result, Err(AiMuxError::NoSuchModel { ref model_type, .. }) if model_type == "languageModel"), + "expected NoSuchModel error" ); } diff --git a/aimux-providers/tests/searxng_test.rs b/aimux-providers/tests/searxng_test.rs index fac774f35..919f8edf3 100644 --- a/aimux-providers/tests/searxng_test.rs +++ b/aimux-providers/tests/searxng_test.rs @@ -175,24 +175,25 @@ async fn status_401_maps_to_auth_error() { // -- Provider trait ---------------------------------------------------------- #[tokio::test] -async fn provider_name_is_searxng() { +async fn search_model_provider_is_searxng() { let config = SearxngConfig::new("http://localhost:8080"); let provider = SearxngProvider::new(config); - assert_eq!(provider.name(), "searxng"); + assert_eq!(provider.search_model().provider(), "searxng"); } #[test] -fn language_model_returns_unsupported_error() { +fn language_model_returns_no_such_model() { let config = SearxngConfig::new("http://localhost:8080"); let provider = SearxngProvider::new(config); match provider.language_model("searxng-search") { - Err(AiMuxError::UnsupportedFunctionality(msg)) => { - assert!( - msg.contains("provider 'searxng' does not provide language models"), - "unexpected message: {msg}" - ); + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, "searxng-search"); + assert_eq!(model_type, "languageModel"); } - _ => panic!("expected Unsupported error, got success or another error variant"), + _ => panic!("expected NoSuchModel error, got success or another error variant"), } } diff --git a/aimux-providers/tests/serper_test.rs b/aimux-providers/tests/serper_test.rs index b673194a9..1e917c0ef 100644 --- a/aimux-providers/tests/serper_test.rs +++ b/aimux-providers/tests/serper_test.rs @@ -14,9 +14,9 @@ fn make_provider(server: &MockServer) -> SerperProvider { } #[test] -fn provider_name_is_serper() { +fn search_model_provider_is_serper() { let provider = SerperProvider::new(SerperConfig::new("test-key")); - assert_eq!(provider.name(), "serper"); + assert_eq!(provider.search_model().provider(), "serper"); } #[test] diff --git a/aimux-providers/tests/tavily_test.rs b/aimux-providers/tests/tavily_test.rs index 8572b1600..c7a79d9fd 100644 --- a/aimux-providers/tests/tavily_test.rs +++ b/aimux-providers/tests/tavily_test.rs @@ -14,9 +14,9 @@ fn make_provider(server: &MockServer) -> TavilyProvider { } #[test] -fn provider_name_is_tavily() { +fn search_model_provider_is_tavily() { let provider = TavilyProvider::new(TavilyConfig::new("test-key")); - assert_eq!(provider.name(), "tavily"); + assert_eq!(provider.search_model().provider(), "tavily"); } #[test] diff --git a/aimux-providers/tests/thin_wrapper_config_test.rs b/aimux-providers/tests/thin_wrapper_config_test.rs index 7e5624f2f..7792c833f 100644 --- a/aimux-providers/tests/thin_wrapper_config_test.rs +++ b/aimux-providers/tests/thin_wrapper_config_test.rs @@ -7,7 +7,7 @@ //! suites verify: //! //! - `from_env()` loads the correct environment variable. -//! - `Provider::name()` returns the documented provider string. +//! - the provider's models report the documented provider string. //! - Custom headers are forwarded to the HTTP request. //! - The default base URL constant is wired correctly (verified via a mock //! round-trip that relies on `with_base_url`). @@ -69,13 +69,16 @@ mod huggingface_config { use super::*; /// TS: `createHuggingFace()` should create a provider with default - /// configuration. In Rust we verify the provider name and that a model - /// can be created. + /// configuration. In Rust we verify that a model can be created. The chat + /// model is the shared OpenAI-compatible model, so `provider()` carries the + /// protocol name until each wrapper gets its own settings-level name. #[test] - fn provider_name_is_huggingface() { + fn model_is_created_with_default_configuration() { let config = HuggingFaceConfig::new("test-key"); let provider = HuggingFaceProvider::new(config); - assert_eq!(provider.name(), "huggingface"); + let model = provider.model("any"); + assert_eq!(model.model_id(), "any"); + assert_eq!(model.provider(), "openai"); } /// TS: `createHuggingFace({ apiKey: 'custom-key' })` �?custom API key diff --git a/aimux-providers/tests/tinyfish_test.rs b/aimux-providers/tests/tinyfish_test.rs index 5258634b3..c0557c491 100644 --- a/aimux-providers/tests/tinyfish_test.rs +++ b/aimux-providers/tests/tinyfish_test.rs @@ -198,23 +198,24 @@ async fn status_401_maps_to_auth_error() { // -- Provider trait ---------------------------------------------------------- #[tokio::test] -async fn provider_name_is_tinyfish() { +async fn search_model_provider_is_tinyfish() { let config = TinyfishConfig::new(API_KEY); let provider = TinyfishProvider::new(config); - assert_eq!(provider.name(), "tinyfish"); + assert_eq!(provider.search_model().provider(), "tinyfish"); } #[test] -fn language_model_returns_unsupported_error() { +fn language_model_returns_no_such_model() { let config = TinyfishConfig::new(API_KEY); let provider = TinyfishProvider::new(config); match provider.language_model("tinyfish-search") { - Err(AiMuxError::UnsupportedFunctionality(msg)) => { - assert!( - msg.contains("provider 'tinyfish' does not provide language models"), - "unexpected message: {msg}" - ); + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, "tinyfish-search"); + assert_eq!(model_type, "languageModel"); } - _ => panic!("expected Unsupported error, got success or another error variant"), + _ => panic!("expected NoSuchModel error, got success or another error variant"), } } diff --git a/aimux-providers/tests/vertex_remaining_test.rs b/aimux-providers/tests/vertex_remaining_test.rs index 18eaf7874..dbace9837 100644 --- a/aimux-providers/tests/vertex_remaining_test.rs +++ b/aimux-providers/tests/vertex_remaining_test.rs @@ -1,4 +1,4 @@ -//! Remaining Google Vertex AI provider tests — ported from the TS SDK suite. +//! Remaining Google Vertex AI provider tests — ported from the TS SDK suite. //! //! Mirrors `reference/ai/packages/google-vertex/src/google-vertex-provider.test.ts` //! (provider configuration: auth headers, base URL, env-var resolution, project @@ -77,10 +77,13 @@ async fn mock_ok(server: &MockServer) { // ════════════════════════════════════════════════════════════════════════════ #[tokio::test] -async fn provider_name_is_google_vertex() { +async fn model_provider_is_google_vertex() { let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); let provider = VertexProvider::new(config); - assert_eq!(provider.name(), "google.vertex"); + assert_eq!( + provider.model("gemini-2.0-flash").unwrap().provider(), + "google.vertex" + ); } #[tokio::test] diff --git a/aimux-providers/tests/you_com_test.rs b/aimux-providers/tests/you_com_test.rs index 9c7b11fed..f93355c66 100644 --- a/aimux-providers/tests/you_com_test.rs +++ b/aimux-providers/tests/you_com_test.rs @@ -179,23 +179,24 @@ async fn status_401_maps_to_auth_error() { // -- Provider trait ---------------------------------------------------------- #[tokio::test] -async fn provider_name_is_you_com() { +async fn search_model_provider_is_you_com() { let config = YouComConfig::new(API_KEY); let provider = YouComProvider::new(config); - assert_eq!(provider.name(), "you_com"); + assert_eq!(provider.search_model().provider(), "you_com"); } #[test] -fn language_model_returns_unsupported_error() { +fn language_model_returns_no_such_model() { let config = YouComConfig::new(API_KEY); let provider = YouComProvider::new(config); match provider.language_model("youcom-search") { - Err(AiMuxError::UnsupportedFunctionality(msg)) => { - assert!( - msg.contains("provider 'you_com' does not provide language models"), - "unexpected message: {msg}" - ); + Err(AiMuxError::NoSuchModel { + model_id, + model_type, + }) => { + assert_eq!(model_id, "youcom-search"); + assert_eq!(model_type, "languageModel"); } - _ => panic!("expected Unsupported error, got success or another error variant"), + _ => panic!("expected NoSuchModel error, got success or another error variant"), } } diff --git a/bindings/node/__test__/body_overrides.test.ts b/bindings/node/__test__/body_overrides.test.ts deleted file mode 100644 index 67c9e2372..000000000 --- a/bindings/node/__test__/body_overrides.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -// body_overrides.test.ts — Node e2e tests for RFC-0017 phase 1: bodyOverrides -// (JSON deep-merge) and provider factory config (headers/maxRetries/bodyOverrides). -// -// These verify the full chain: JS → napi → Rust → HTTP mock, asserting the -// outbound request body carries the user's overrides. - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { openai, deepseek } from '../src/native.ts' - -function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -function readBody(req: any): Promise { - return new Promise((resolve) => { - let body = '' - req.on('data', (chunk: string) => { body += chunk }) - req.on('end', () => resolve(JSON.parse(body))) - }) -} - -const CHAT_RESPONSE = JSON.stringify({ - id: 'chatcmpl-test', - model: 'gpt-4o', - choices: [{ - message: { role: 'assistant', content: 'Done.' }, - finish_reason: 'stop', - }], - usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, -}) - -// ── per-call bodyOverrides ─────────────────────────────────────────────────── - -test('per-call bodyOverrides injects a field into the request body', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ - body_overrides: { enable_thinking: false }, - }) - await model.generateText(JSON.stringify('Hello'), opts) - - t.is(requestBody.enable_thinking, false) - t.is(requestBody.model, 'gpt-4o') - } finally { - await closeServer(server) - } -}) - -test('per-call bodyOverrides overwrites a standard field', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', url) - // temperature set via standard option, overridden by body_overrides - const opts = JSON.stringify({ - temperature: 0.9, - body_overrides: { temperature: 0.1 }, - }) - await model.generateText(JSON.stringify('Hello'), opts) - - t.is(requestBody.temperature, 0.1) - } finally { - await closeServer(server) - } -}) - -test('per-call bodyOverrides null deletes a field', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ - temperature: 0.5, - body_overrides: { temperature: null }, - }) - await model.generateText(JSON.stringify('Hello'), opts) - - t.true(requestBody.temperature === undefined, 'temperature should be deleted by null') - } finally { - await closeServer(server) - } -}) - -// ── provider-level bodyOverrides (factory config) ──────────────────────────── - -test('factory config bodyOverrides are merged into every request', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', { - baseUrl: url, - bodyOverrides: '{"X-Relay-Tag":"my-team"}', - }) - await model.generateText(JSON.stringify('Hello')) - - t.is(requestBody['X-Relay-Tag'], 'my-team') - } finally { - await closeServer(server) - } -}) - -test('factory config headers are sent on every request', async (t) => { - let headers: any = null - const { server, url } = await startMockServer(async (req, res) => { - headers = req.headers - // consume body - await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', { - baseUrl: url, - headers: JSON.stringify({ 'X-Custom-Header': 'custom-value' }), - }) - await model.generateText(JSON.stringify('Hello')) - - t.is(headers['x-custom-header'], 'custom-value') - } finally { - await closeServer(server) - } -}) - -// ── backward compatibility ─────────────────────────────────────────────────── - -test('factory accepts bare string baseUrl (backward compatible)', async (t) => { - const { server, url } = await startMockServer(async (req, res) => { - await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - // 3rd param is a plain string (old API) - const model = await openai('test-key', 'gpt-4o', url) - const result = JSON.parse(await model.generateText(JSON.stringify('Hello'))) - t.is(result.text, 'Done.') - } finally { - await closeServer(server) - } -}) - -// ── per-call overrides provider-level ──────────────────────────────────────── - -test('per-call bodyOverrides take precedence over provider-level', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', { - baseUrl: url, - bodyOverrides: '{"custom_field":"provider"}', - }) - const opts = JSON.stringify({ - body_overrides: { custom_field: 'call' }, - }) - await model.generateText(JSON.stringify('Hello'), opts) - - t.is(requestBody.custom_field, 'call', 'per-call should override provider-level') - } finally { - await closeServer(server) - } -}) - -// ── maxRetries ──────────────────────────────────────────────────────────────── - -test('maxRetries: 0 disables retries (single request on 500)', async (t) => { - let requestCount = 0 - const { server, url } = await startMockServer(async (req, res) => { - requestCount++ - await readBody(req) - res.writeHead(500, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ error: { message: 'Internal server error', type: 'server_error' } })) - }) - - try { - const model = await openai('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ max_retries: 0 }) - - await t.throwsAsync( - async () => model.generateText(JSON.stringify('Hello'), opts), - { message: /500|Internal|server_error/i }, - ) - - // With retries disabled, exactly 1 request should have been made. - t.is(requestCount, 1, 'should make exactly 1 request when maxRetries=0') - } finally { - await closeServer(server) - } -}) - -test('maxRetries: factory-level disables retries', async (t) => { - let requestCount = 0 - const { server, url } = await startMockServer(async (req, res) => { - requestCount++ - await readBody(req) - res.writeHead(500, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ error: { message: 'Internal server error', type: 'server_error' } })) - }) - - try { - const model = await openai('test-key', 'gpt-4o', { - baseUrl: url, - maxRetries: 0, - }) - - await t.throwsAsync( - async () => model.generateText(JSON.stringify('Hello')), - { message: /500|Internal|server_error/i }, - ) - - t.is(requestCount, 1, 'factory maxRetries:0 should make exactly 1 request') - } finally { - await closeServer(server) - } -}) diff --git a/bindings/node/__test__/error.test.ts b/bindings/node/__test__/error.test.ts index 5540c45e5..61e47c010 100644 --- a/bindings/node/__test__/error.test.ts +++ b/bindings/node/__test__/error.test.ts @@ -62,10 +62,9 @@ test('stream error path yields a typed AimuxError', async (t) => { // Unreachable port: connection is refused immediately; no retries. const model = await openai('test-key', 'gpt-4o', { baseUrl: 'http://127.0.0.1:1', - maxRetries: 0, }) const err = (await t.throwsAsync(async () => { - for await (const _part of streamText(model, 'hello')) { + for await (const _part of streamText(model, 'hello', { max_retries: 0 })) { // never yields } })) as AimuxError @@ -116,9 +115,9 @@ test('APICallError carries every field the response produced', async (t) => { res.end(body) }) try { - const model = await native.openai('test-key', 'gpt-4o', { baseUrl: url, maxRetries: 0 }) + const model = await native.openai('test-key', 'gpt-4o', { baseUrl: url }) const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi')), + model.generateText(JSON.stringify('hi'), JSON.stringify({ max_retries: 0 })), )) as APICallError t.true(err instanceof APICallError) t.true(err instanceof native.APICallError) @@ -153,9 +152,9 @@ test('exhausted retries throw RetryError with the per-attempt history', async (t res.end(body) }) try { - const model = await native.openai('test-key', 'gpt-4o', { baseUrl: url, maxRetries: 1 }) + const model = await native.openai('test-key', 'gpt-4o', { baseUrl: url }) const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi')), + model.generateText(JSON.stringify('hi'), JSON.stringify({ max_retries: 1 })), )) as RetryError t.true(err instanceof RetryError) t.true(err instanceof AimuxError) @@ -209,7 +208,7 @@ test('typed initRecordingRing keeps core errors in the AimuxError hierarchy', (t }) test('malformed prompt JSON is a plain napi InvalidArg error, not a core error', async (t) => { - const model = await openai('test-key', 'gpt-4o', { baseUrl: 'http://127.0.0.1:1', maxRetries: 0 }) + const model = await openai('test-key', 'gpt-4o', { baseUrl: 'http://127.0.0.1:1' }) // The typed generateText() always stringifies; hit the raw JSON-text path. const err = (await t.throwsAsync(() => model.generateText('{not json'))) as Error & { code?: string diff --git a/bindings/node/__test__/provider_config.test.ts b/bindings/node/__test__/provider_config.test.ts new file mode 100644 index 000000000..cac23e4c3 --- /dev/null +++ b/bindings/node/__test__/provider_config.test.ts @@ -0,0 +1,154 @@ +// provider_config.test.ts — Node e2e tests for the provider factory config +// (headers) and the call-level maxRetries, plus the removed bodyOverrides and +// the provider-level maxRetries, which are rejected instead of ignored. +// +// These verify the full chain: JS → napi → Rust → HTTP mock. + +import test from 'ava' +import { createServer, type Server } from 'node:http' +import { openai } from '../src/native.ts' + +function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { + return new Promise((resolve) => { + const server = createServer(handler) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as any + resolve({ server, url: `http://127.0.0.1:${addr.port}` }) + }) + }) +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => server.close(() => resolve())) +} + +function readBody(req: any): Promise { + return new Promise((resolve) => { + let body = '' + req.on('data', (chunk: string) => { body += chunk }) + req.on('end', () => resolve(JSON.parse(body))) + }) +} + +const CHAT_RESPONSE = JSON.stringify({ + id: 'chatcmpl-test', + model: 'gpt-4o', + choices: [{ + message: { role: 'assistant', content: 'Done.' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, +}) + +// ── removed: bodyOverrides ─────────────────────────────────────────────────── + +test('per-call body_overrides no longer reaches the request body', async (t) => { + let requestBody: any = null + const { server, url } = await startMockServer(async (req, res) => { + requestBody = await readBody(req) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(CHAT_RESPONSE) + }) + + try { + const model = await openai('test-key', 'gpt-4o', url) + const opts = JSON.stringify({ + temperature: 0.9, + body_overrides: { temperature: 0.1, enable_thinking: false }, + }) + await model.generateText(JSON.stringify('Hello'), opts) + + t.is(requestBody.temperature, 0.9, 'the standard option stands') + t.true(requestBody.enable_thinking === undefined, 'the override is not applied') + } finally { + await closeServer(server) + } +}) + +test('factory config bodyOverrides is rejected, not ignored', async (t) => { + const err = await t.throwsAsync(() => + openai('test-key', 'gpt-4o', { + baseUrl: 'http://127.0.0.1:1', + bodyOverrides: '{"X-Relay-Tag":"my-team"}', + }), + ) + t.true(err instanceof Error) + t.regex(err!.message, /body_overrides/) +}) + +test('factory config maxRetries is rejected, not ignored', async (t) => { + const err = await t.throwsAsync(() => + openai('test-key', 'gpt-4o', { baseUrl: 'http://127.0.0.1:1', maxRetries: 0 }), + ) + t.true(err instanceof Error) + t.regex(err!.message, /max_retries/) +}) + +test('factory config headers are sent on every request', async (t) => { + let headers: any = null + const { server, url } = await startMockServer(async (req, res) => { + headers = req.headers + // consume body + await readBody(req) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(CHAT_RESPONSE) + }) + + try { + const model = await openai('test-key', 'gpt-4o', { + baseUrl: url, + headers: JSON.stringify({ 'X-Custom-Header': 'custom-value' }), + }) + await model.generateText(JSON.stringify('Hello')) + + t.is(headers['x-custom-header'], 'custom-value') + } finally { + await closeServer(server) + } +}) + +// ── backward compatibility ─────────────────────────────────────────────────── + +test('factory accepts bare string baseUrl (backward compatible)', async (t) => { + const { server, url } = await startMockServer(async (req, res) => { + await readBody(req) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(CHAT_RESPONSE) + }) + + try { + // 3rd param is a plain string (old API) + const model = await openai('test-key', 'gpt-4o', url) + const result = JSON.parse(await model.generateText(JSON.stringify('Hello'))) + t.is(result.text, 'Done.') + } finally { + await closeServer(server) + } +}) + +// ── maxRetries ──────────────────────────────────────────────────────────────── + +test('maxRetries: 0 disables retries (single request on 500)', async (t) => { + let requestCount = 0 + const { server, url } = await startMockServer(async (req, res) => { + requestCount++ + await readBody(req) + res.writeHead(500, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: { message: 'Internal server error', type: 'server_error' } })) + }) + + try { + const model = await openai('test-key', 'gpt-4o', url) + const opts = JSON.stringify({ max_retries: 0 }) + + await t.throwsAsync( + async () => model.generateText(JSON.stringify('Hello'), opts), + { message: /500|Internal|server_error/i }, + ) + + // With retries disabled, exactly 1 request should have been made. + t.is(requestCount, 1, 'should make exactly 1 request when maxRetries=0') + } finally { + await closeServer(server) + } +}) diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 9b727d44d..74925afff 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -484,15 +484,15 @@ export interface ProviderConfig { /** OpenAI project ID (sent via `OpenAI-Project` header). */ project?: string /** - * No longer applied: retry is a per-call setting (`maxRetries` in the - * call options). TODO(A5): remove this field and reject it. + * Rejected with `InvalidArgumentError`: retry is a per-call setting + * (`maxRetries` in the call options). The field exists only so passing it + * is reported instead of ignored. */ maxRetries?: number /** - * Provider-level request body overrides as a JSON string (deep-merged - * into every request). Per-call `bodyOverrides` in GenerateTextOptions - * takes precedence. Pass a JSON object string, e.g. - * `'{"enable_thinking": false}'`. + * Rejected with `InvalidArgumentError`: request-body overrides were + * removed. The field exists only so passing it is reported instead of + * ignored. */ bodyOverrides?: string } diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index dd1afae21..e28e4562c 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -614,21 +614,33 @@ pub struct ProviderConfig { pub organization: Option, /// OpenAI project ID (sent via `OpenAI-Project` header). pub project: Option, - /// No longer applied: retry is a per-call setting (`maxRetries` in the - /// call options). TODO(A5): remove this field and reject it. + /// Rejected with `InvalidArgumentError`: retry is a per-call setting + /// (`maxRetries` in the call options). The field exists only so passing it + /// is reported instead of ignored. pub max_retries: Option, - /// Provider-level request body overrides as a JSON string (deep-merged - /// into every request). Per-call `bodyOverrides` in GenerateTextOptions - /// takes precedence. Pass a JSON object string, e.g. - /// `'{"enable_thinking": false}'`. + /// Rejected with `InvalidArgumentError`: request-body overrides were + /// removed. The field exists only so passing it is reported instead of + /// ignored. pub body_overrides: Option, } +impl ProviderConfig { + /// Fail on the two removed keys instead of dropping them silently. + fn reject_removed_keys(&self) -> MResult<()> { + aimux_providers::reject_removed_provider_options( + self.max_retries.is_some(), + self.body_overrides.is_some(), + ) + .map_err(|e| AiMuxBindingError::from(&e)) + } +} + /// Convert a ProviderConfig (from JS) into OpenAIConfig builder steps. fn apply_provider_config_openai( mut config: aimux_providers::openai::OpenAIConfig, cfg: &ProviderConfig, ) -> MResult { + cfg.reject_removed_keys()?; if let Some(url) = &cfg.base_url { config = config.with_base_url(url); } @@ -643,10 +655,6 @@ fn apply_provider_config_openai( if let Some(ref proj) = cfg.project { config = config.with_project(proj); } - if let Some(ref json_str) = cfg.body_overrides { - let overrides: serde_json::Value = parse_wire_json("config.bodyOverrides", json_str)?; - config = config.with_body_overrides(overrides); - } Ok(config) } @@ -997,6 +1005,7 @@ pub async fn anthropic( cfg = cfg.with_base_url(url); } Some(Either::B(opts)) => { + opts.reject_removed_keys()?; if let Some(url) = &opts.base_url { cfg = cfg.with_base_url(url); } @@ -1005,11 +1014,6 @@ pub async fn anthropic( parse_wire_json("config.headers", json_str)?; cfg = cfg.with_headers(h); } - if let Some(ref json_str) = opts.body_overrides { - let overrides: serde_json::Value = - parse_wire_json("config.bodyOverrides", json_str)?; - cfg = cfg.with_body_overrides(overrides); - } } None => {} } @@ -1423,6 +1427,7 @@ pub async fn provider( #[napi] pub struct ProviderHandle { inner: Arc, + discovery: Arc, } #[napi] @@ -1437,7 +1442,7 @@ impl ProviderHandle { AimuxResult({ let __r: crate::error::MResult = async { let models = self - .inner + .discovery .list_models() .await .map_err(|e| AiMuxBindingError::from(&e))?; @@ -1490,11 +1495,11 @@ pub async fn create_provider( Some(cfg) => provider_options_from_config(Some(Either::B(cfg)))?, None => None, }; - let p = aimux_providers::provider_handle(&name, api_key, options) + let inner = aimux_providers::provider_handle(&name, api_key.clone(), options.clone()) .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(ProviderHandle { - inner: Arc::from(p), - }) + let discovery = aimux_providers::provider_discovery(&name, api_key, options) + .map_err(|e| AiMuxBindingError::from(&e))?; + Ok(ProviderHandle { inner, discovery }) } .await; __r @@ -1529,6 +1534,7 @@ fn provider_options_from_config( ..Default::default() }), Some(Either::B(cfg)) => { + cfg.reject_removed_keys()?; let mut o = aimux_providers::ProviderOptions::default(); if let Some(url) = cfg.base_url { o.base_url = Some(url); @@ -1542,12 +1548,6 @@ fn provider_options_from_config( if let Some(proj) = cfg.project { o.project = Some(proj); } - if let Some(max) = cfg.max_retries { - o.max_retries = Some(max); - } - if let Some(ref json_str) = cfg.body_overrides { - o.body_overrides = Some(parse_wire_json("config.bodyOverrides", json_str)?); - } Some(o) } }; diff --git a/bindings/node/src/types/CallOptions.ts b/bindings/node/src/types/CallOptions.ts index 53c072370..5bef1aeb4 100644 --- a/bindings/node/src/types/CallOptions.ts +++ b/bindings/node/src/types/CallOptions.ts @@ -76,12 +76,6 @@ provider_options: { [key in string]: JsonValue } | null, * Anthropic `thinking` config. */ reasoning: ReasoningEffort | null, -/** - * Per-call request body overrides. Deep-merged into the provider-built - * request body (after any built-in vendor override) before sending. - * `null` values delete the corresponding key. See RFC-0017. - */ -body_overrides: JsonValue | null, /** * Per-call retry count override. `None` uses the provider's configured * Core operation retry. `Some(0)` disables retries. diff --git a/bindings/node/src/types/GenerateTextOptions.ts b/bindings/node/src/types/GenerateTextOptions.ts index 00b49da24..7a256f4ae 100644 --- a/bindings/node/src/types/GenerateTextOptions.ts +++ b/bindings/node/src/types/GenerateTextOptions.ts @@ -21,10 +21,6 @@ reasoning: ReasoningEffort | null, * System instructions prepended to the prompt. */ instructions: string | null, -/** - * Per-call request body overrides (deep-merged). See RFC-0017. - */ -body_overrides: JsonValue | null, /** * Per-call retry count override. `None` = provider default, `Some(0)` = disable. */ diff --git a/bindings/node/src/types/InputRecord.ts b/bindings/node/src/types/InputRecord.ts index fb203c0ba..5197226a8 100644 --- a/bindings/node/src/types/InputRecord.ts +++ b/bindings/node/src/types/InputRecord.ts @@ -12,6 +12,6 @@ export type InputRecord = { prompt: Array, /** * 序列化的 CallOptions(abort_signal/call_id 已 serde skip); - * headers/provider_options/body_overrides 已递归脱敏。 + * headers/provider_options 已递归脱敏。 */ options: JsonValue, }; diff --git a/bindings/node/src/types/ModelSpec.ts b/bindings/node/src/types/ModelSpec.ts index 58eb9bd31..69ead413e 100644 --- a/bindings/node/src/types/ModelSpec.ts +++ b/bindings/node/src/types/ModelSpec.ts @@ -12,8 +12,7 @@ import type { JsonValue } from "./serde_json/JsonValue"; * The full static portrait for a model, supplemented from community knowledge. * * All fields are advisory — aimux never auto-applies them in the request path. - * The caller reads them to decide how to configure `GenerateTextOptions` / - * `bodyOverrides`. + * The caller reads them to decide how to configure `GenerateTextOptions`. */ export type ModelSpec = { display_name?: string | null, type: ModelType, limits: ModelLimits, modalities: ModelModalities, capabilities: ModelCapabilities, reasoning?: ReasoningSpec | null, cost?: ModelCost | null, source: CatalogueSource, /** diff --git a/bindings/node/src/types/ProviderRecord.ts b/bindings/node/src/types/ProviderRecord.ts index 4af13e674..a59cfc113 100644 --- a/bindings/node/src/types/ProviderRecord.ts +++ b/bindings/node/src/types/ProviderRecord.ts @@ -1,31 +1,21 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "./serde_json/JsonValue"; /** * ② 配置侧:provider 身份(请求回放重建用)。 + * + * 只记身份,不记配置:回放时按 `provider_id` + `model_id` 重新构建 model, + * 凭证与 base URL 只来自环境/registry(RFC-0036 §3.3)。 */ export type ProviderRecord = { /** - * `model.provider()`,如 "openai"。 + * provider 名(registry 名),回放重建的查找键,如 "openai"、"deepseek"。 */ -provider: string, -/** - * `model.model_id()`,如 "gpt-4o"。 - */ -model_id: string, -/** - * provider 的 base_url。 - */ -base_url: string | null, -/** - * api_key 来源(不存明文):"env:OPENAI_API_KEY" / "explicit" / "none" / "unknown"。 - */ -api_key_source: string, +provider_id: string, /** - * OpenAICompatProfile(能力差异)。 + * `model.provider()`,如 "openai.chat"。 */ -profile: JsonValue | null, +provider: string, /** - * ProviderOptions(headers/org/project/...;已脱敏)。 + * `model.model_id()`,如 "gpt-4o"。 */ -provider_options: JsonValue | null, }; +model_id: string, }; diff --git a/bindings/node/src/types/Recording.ts b/bindings/node/src/types/Recording.ts index 3bd5bc35c..8ef090200 100644 --- a/bindings/node/src/types/Recording.ts +++ b/bindings/node/src/types/Recording.ts @@ -9,7 +9,8 @@ import type { ProviderRecord } from "./ProviderRecord"; */ export type Recording = { /** - * 格式版本。 + * 格式版本。读取时只接受当前 [`RECORDING_SCHEMA`];旧 schema 的录制带有 + * 配置快照(base_url / profile 等),不再读取。 */ schema: number, /** diff --git a/bindings/python/python/aimux/__init__.py b/bindings/python/python/aimux/__init__.py index 0908fd439..20be5c5e1 100644 --- a/bindings/python/python/aimux/__init__.py +++ b/bindings/python/python/aimux/__init__.py @@ -197,7 +197,8 @@ def provider( model_id: Model ID. base_url: Base-URL override (wins over config["base_url"]). config: Full ProviderOptions dict — base_url / headers / organization / - project / max_retries / body_overrides. + project. ``max_retries`` (a call-level option) and ``body_overrides`` + are rejected with an error. """ config_json = json.dumps(config) if config is not None else None return _native_provider(name, api_key, model_id, base_url, config_json) @@ -219,7 +220,8 @@ def create_provider( api_key: API key; None reads the provider's env var. base_url: Base-URL override (wins over config["base_url"]). config: Full ProviderOptions dict — base_url / headers / organization / - project / max_retries / body_overrides. + project. ``max_retries`` (a call-level option) and ``body_overrides`` + are rejected with an error. """ config_json = json.dumps(config) if config is not None else None return _native_create_provider(name, api_key, base_url, config_json) diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index 86e683262..fa39a2592 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -897,7 +897,6 @@ class GenerateTextOptions(BaseModel): provider_options: Optional[Dict[str, Any]] = None reasoning: Optional[ReasoningEffort] = None instructions: Optional[str] = None - body_overrides: Optional[Any] = None max_retries: Optional[int] = None session_id: Optional[str] = None """Session identifier (RFC-0024): groups consecutive calls into a session. diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index eb147ce3c..4fae21f69 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -682,8 +682,9 @@ fn azure( /// Create a language model from the built-in registry by provider name /// (RFC-0017 phase 4). `api_key=None` reads the provider's env var. /// `config_json` is a serialized `ProviderOptions` object (`base_url` / -/// `headers` / `organization` / `project` / `max_retries` / -/// `body_overrides`); the `base_url` parameter wins over the JSON field. +/// `headers` / `organization` / `project`; `max_retries` and `body_overrides` +/// are rejected as invalid arguments); the `base_url` parameter wins over the +/// JSON field. #[pyfunction] #[pyo3(signature = (name, api_key, model_id, base_url=None, config_json=None))] fn provider( @@ -717,6 +718,7 @@ fn provider( #[pyclass] struct ProviderHandle { inner: Arc, + discovery: Arc, } #[pymethods] @@ -726,7 +728,7 @@ impl ProviderHandle { fn list_models(&self) -> PyResult { let rt = runtime(); let models = rt - .block_on(async { self.inner.list_models().await }) + .block_on(async { self.discovery.list_models().await }) .map_err(|e| to_py_err(&e))?; serialize_result(&models) } @@ -763,10 +765,11 @@ fn create_provider( if let Some(url) = base_url { options.get_or_insert_with(Default::default).base_url = Some(url.to_string()); } - let p = aimux_providers::provider_handle(name, api_key, options).map_err(|e| to_py_err(&e))?; - Ok(ProviderHandle { - inner: Arc::from(p), - }) + let inner = aimux_providers::provider_handle(name, api_key.clone(), options.clone()) + .map_err(|e| to_py_err(&e))?; + let discovery = + aimux_providers::provider_discovery(name, api_key, options).map_err(|e| to_py_err(&e))?; + Ok(ProviderHandle { inner, discovery }) } /// Fetch the community model catalogue (RFC-0027) and return it as a JSON diff --git a/bindings/python/tests/test_aimux.py b/bindings/python/tests/test_aimux.py index 34b177d03..e46787c3b 100644 --- a/bindings/python/tests/test_aimux.py +++ b/bindings/python/tests/test_aimux.py @@ -51,14 +51,24 @@ def test_provider_accepts_full_config(): "headers": {"X-Custom": "1"}, "organization": "org-1", "project": "proj-1", - "max_retries": 0, - "body_overrides": {"temperature": 0.1}, }, ) assert model is not None assert hasattr(model, "generate_text") +@pytest.mark.parametrize( + "key,value", + [("max_retries", 0), ("body_overrides", {"temperature": 0.1})], +) +def test_provider_rejects_removed_config_keys(key, value): + """max_retries is call-level and body_overrides is gone: neither is silently ignored.""" + from aimux import provider + + with pytest.raises(Exception, match=key): + provider("groq", "sk-test-fake-key", "llama-3.3-70b", config={key: value}) + + def test_provider_base_url_param_wins_over_config(): """Explicit base_url parameter overrides config["base_url"].""" from aimux import provider @@ -82,7 +92,7 @@ def test_provider_invalid_config_raises(): "groq", "sk-test-fake-key", "llama-3.3-70b", - config={"max_retries": "not-a-number"}, + config={"headers": "not-a-map"}, ) diff --git a/contract-tests/fixtures/wire-format.json b/contract-tests/fixtures/wire-format.json index b6f414055..c58e56b15 100644 --- a/contract-tests/fixtures/wire-format.json +++ b/contract-tests/fixtures/wire-format.json @@ -55,26 +55,26 @@ { "name": "generate_text_options_default", "type": "GenerateTextOptions", - "json": "{\"max_output_tokens\":null,\"temperature\":null,\"stop_sequences\":null,\"top_p\":null,\"top_k\":null,\"presence_penalty\":null,\"frequency_penalty\":null,\"response_format\":null,\"seed\":null,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"body_overrides\":null,\"max_retries\":null,\"timeout\":null,\"include_raw_chunks\":null}", + "json": "{\"max_output_tokens\":null,\"temperature\":null,\"stop_sequences\":null,\"top_p\":null,\"top_k\":null,\"presence_penalty\":null,\"frequency_penalty\":null,\"response_format\":null,\"seed\":null,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"max_retries\":null,\"timeout\":null,\"include_raw_chunks\":null}", "description": "Default GenerateTextOptions with all fields null" }, { "name": "generate_text_options_with_session_id", "type": "GenerateTextOptions", - "json": "{\"max_output_tokens\":null,\"temperature\":null,\"stop_sequences\":null,\"top_p\":null,\"top_k\":null,\"presence_penalty\":null,\"frequency_penalty\":null,\"response_format\":null,\"seed\":null,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"body_overrides\":null,\"max_retries\":null,\"timeout\":null,\"session_id\":\"sess-1\",\"include_raw_chunks\":null}", + "json": "{\"max_output_tokens\":null,\"temperature\":null,\"stop_sequences\":null,\"top_p\":null,\"top_k\":null,\"presence_penalty\":null,\"frequency_penalty\":null,\"response_format\":null,\"seed\":null,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"max_retries\":null,\"timeout\":null,\"session_id\":\"sess-1\",\"include_raw_chunks\":null}", "description": "GenerateTextOptions with explicit session_id (RFC-0024)" }, { "name": "generate_text_options_include_raw_chunks_true", "type": "GenerateTextOptions", "rust_value": "", - "json": "{\"max_output_tokens\":null,\"temperature\":null,\"stop_sequences\":null,\"top_p\":null,\"top_k\":null,\"presence_penalty\":null,\"frequency_penalty\":null,\"response_format\":null,\"seed\":null,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"body_overrides\":null,\"max_retries\":null,\"timeout\":null,\"include_raw_chunks\":true}", + "json": "{\"max_output_tokens\":null,\"temperature\":null,\"stop_sequences\":null,\"top_p\":null,\"top_k\":null,\"presence_penalty\":null,\"frequency_penalty\":null,\"response_format\":null,\"seed\":null,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"max_retries\":null,\"timeout\":null,\"include_raw_chunks\":true}", "description": "GenerateTextOptions with include_raw_chunks=true (RFC-0016 M2 true-case)" }, { "name": "generate_text_options_numeric_types", "type": "GenerateTextOptions", - "json": "{\"max_output_tokens\":256,\"temperature\":0.7,\"stop_sequences\":null,\"top_p\":0.95,\"top_k\":40.5,\"presence_penalty\":0.5,\"frequency_penalty\":-0.5,\"response_format\":null,\"seed\":42,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"body_overrides\":null,\"max_retries\":3,\"timeout\":null,\"include_raw_chunks\":null}", + "json": "{\"max_output_tokens\":256,\"temperature\":0.7,\"stop_sequences\":null,\"top_p\":0.95,\"top_k\":40.5,\"presence_penalty\":0.5,\"frequency_penalty\":-0.5,\"response_format\":null,\"seed\":42,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"max_retries\":3,\"timeout\":null,\"include_raw_chunks\":null}", "description": "Numeric fields carry non-null, type-discriminating values so every binding's declared types are exercised. top_k is deliberately FRACTIONAL: Rust is Option (matching the upstream AI SDK's `topK?: number`), so a binding that declares it as an integer cannot hold this value. Integer fields (max_output_tokens u32, seed u64, max_retries u32) and a negative penalty pin the signedness. This case exists to lock TYPES, not to model a realistic call." }, { diff --git a/scripts/check_provider_boundaries.sh b/scripts/check_provider_boundaries.sh new file mode 100755 index 000000000..2e086e113 --- /dev/null +++ b/scripts/check_provider_boundaries.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Provider boundary grep gates (RFC-0036, AI SDK architecture alignment). +# +# Keeps the provider layer on the same side of the line as the AI SDK: +# 1. Providers never read call-level plumbing (retry budget, timeouts, +# session / call ids) from `CallOptions`. Core owns retry and recording; +# a provider hands the options to `HttpRequest::new(url, headers, options)` +# and lets the transport read what it needs. +# 2. Names removed by the provider-factory rewrite stay removed. +# +# Provider-name literal rules arrive with the preset generator (A3); they are +# deliberately absent here. +# +# Usage: bash scripts/check_provider_boundaries.sh (exit 1 on any violation) + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +status=0 + +# violation : print and remember a failure when the grep +# output is non-empty. +violation() { + local rule="$1" hits="$2" + if [[ -n "$hits" ]]; then + printf 'provider boundary violation: %s\n%s\n\n' "$rule" "$hits" >&2 + status=1 + fi +} + +# ── Rule 1: no call-level reads inside aimux-providers/src ─────────────────── +# +# Allowed: lines that build `HttpRequest::new(`, and aimux-providers/src/replay.rs. +# WebSocket realtime sessions are the one transport that the provider itself +# opens, so they carry `options.timeout` into `WebSocketRequest` (no core +# operation owns that timeout); the two sites are listed explicitly. +rule1_pattern='options\.(max_retries|timeout|session_id|call_id)\b' +rule1_hits="$( + { grep -rnE "$rule1_pattern" aimux-providers/src --include='*.rs' || true; } | + grep -v 'HttpRequest::new(' | + grep -v '^aimux-providers/src/replay\.rs:' | + grep -vE '^aimux-providers/src/(elevenlabs\.rs|openai/transcription\.rs):[0-9]+: *timeout: options\.timeout,$' || + true +)" +violation 'aimux-providers/src reads options.max_retries / timeout / session_id / call_id' "$rule1_hits" + +# ── Rule 2: removed names stay removed (whole repository) ──────────────────── +# +# Source files only; docs/, rfc/, fixtures/, build output and vendored +# node_modules are history or data, not code. +search_removed() { + local pattern="$1" + grep -rnE "$pattern" . \ + --include='*.rs' --include='*.py' --include='*.pyi' --include='*.ts' \ + --include='*.js' --include='*.go' --include='*.java' --include='*.kt' \ + --include='*.swift' --include='*.dart' --include='*.c' --include='*.h' \ + --exclude-dir=target --exclude-dir=docs --exclude-dir=rfc \ + --exclude-dir=fixtures --exclude-dir=node_modules --exclude-dir=.git || + true +} + +for pattern in \ + 'fn config_snapshot' \ + 'fn retry_config' \ + 'RetryConfig' \ + 'Provider::name' \ + 'fn specification_version'; do + violation "removed name \`$pattern\` is back" "$(search_removed "$pattern")" +done + +# `shared_client()` lives only in the injectable transport. +shared_client_hits="$( + search_removed 'shared_client\(\)' | + grep -v '^\./aimux-provider-utils/src/fetch\.rs:' || + true +)" +violation 'shared_client() used outside aimux-provider-utils/src/fetch.rs' "$shared_client_hits" + +if [[ "$status" -eq 0 ]]; then + echo 'provider boundaries: ok' +fi +exit "$status" diff --git a/tools/aimux-cli/src/probe/provider.rs b/tools/aimux-cli/src/probe/provider.rs index c76569546..493f173f9 100644 --- a/tools/aimux-cli/src/probe/provider.rs +++ b/tools/aimux-cli/src/probe/provider.rs @@ -116,7 +116,7 @@ fn build_model( } let model = aimux_providers::provider(provider, Some(api_key), model_id, Some(options)) .map_err(|e| anyhow::anyhow!("provider '{provider}': {e}"))?; - Ok(Arc::from(model)) + Ok(model) } } } diff --git a/tools/aimux-replay/src/main.rs b/tools/aimux-replay/src/main.rs index 0eafdd62e..dfb6547b0 100644 --- a/tools/aimux-replay/src/main.rs +++ b/tools/aimux-replay/src/main.rs @@ -1,15 +1,16 @@ //! aimux-replay — RFC-0023 请求回放 CLI(层 2 消费端)。 //! -//! 读录制 jsonl(每行一个 `Recording`),按 `ProviderRecord` 自动重建 -//! provider(OpenAI 兼容族),再用录制输入经 `replay_with_model` **重发真实 -//! API**。用途:离线重跑线上流量、改 prompt 重发(A/B)、回归对比、CI 集成。 +//! 读录制 jsonl(每行一个 `Recording`,仅 schema 3),按 `ProviderRecord` 的 +//! `provider_id` + `model_id` 重建 model,再用录制输入经 `replay_with_model` +//! **重发真实 API**。用途:离线重跑线上流量、改 prompt 重发(A/B)、回归对比、CI 集成。 //! //! 安全: -//! - `--dry-run` 只打印重建后的 provider/prompt/目标 URL,**不发请求、 -//! 不输出任何凭据**(api_key 来源按 `api_key_source` 打印)。 +//! - `--dry-run` 只打印录制的 provider/model/prompt,**不发请求、 +//! 不输出任何凭据**。 //! - 重发会消耗真实 token/费用,文档(§3.6.1)已有警示。 -//! - 原生协议 provider(anthropic/google/...)`rebuild_provider` 明确 -//! `Unsupported`——CLI 打印错误并跳过,需自行传 model 实例走库 API。 +//! - 原生协议 provider(anthropic/google/...)暂无 registry 条目, +//! `rebuild_provider` 返回 `NoSuchProvider`——CLI 打印错误并跳过,需自行传 +//! model 实例走库 API。 use std::path::PathBuf; @@ -33,13 +34,13 @@ struct Cli { /// 仅回放该 call_id(默认全部)。 #[arg(long)] call_id: Option, - /// 显式 api key。api_key_source 为 explicit/unknown 时需要。 + /// 显式 api key;缺省时读 registry 条目的环境变量。 #[arg(long)] api_key: Option, /// 覆盖 prompt(替换所有录制的 prompt;A/B 重发)。 #[arg(long)] prompt: Option, - /// dry-run:打印重建后的 provider/prompt/目标 URL,不发请求、不输出凭据。 + /// dry-run:打印录制的 provider/model/prompt,不发请求、不输出凭据。 #[arg(long)] dry_run: bool, } @@ -99,14 +100,9 @@ fn load_recordings(path: &PathBuf) -> Result> { fn run_dry(rec: &Recording) -> Result<()> { println!("call_id: {}", rec.call_id); println!( - " provider: {} / model: {}", - rec.provider.provider, rec.provider.model_id + " provider_id: {} / provider: {} / model: {}", + rec.provider.provider_id, rec.provider.provider, rec.provider.model_id ); - println!( - " base_url: {}", - rec.provider.base_url.as_deref().unwrap_or("(default)") - ); - println!(" api_key_source: {}", rec.provider.api_key_source); println!(" prompt: {} message(s)", rec.input.prompt.len()); if let Some(text) = rec.input.prompt.first().and_then(prompt_text) { println!(" first message: {text:?}"); @@ -128,7 +124,7 @@ fn prompt_text( /// 真实回放:重建 provider → replay_with_model → 打印结果。 fn run_replay(rec: &Recording, api_key: Option<&str>, prompt: Option<&str>) -> Result<()> { let model = rebuild_provider(&rec.provider, api_key) - .with_context(|| format!("rebuild provider for '{}'", rec.provider.provider))?; + .with_context(|| format!("rebuild provider for '{}'", rec.provider.provider_id))?; let overrides = prompt.map(|text| ReplayOverrides { prompt: Some(vec![ diff --git a/tools/aimux-web/examples/gen_demo_fixtures.rs b/tools/aimux-web/examples/gen_demo_fixtures.rs index 7a0792c9e..92de57a61 100644 --- a/tools/aimux-web/examples/gen_demo_fixtures.rs +++ b/tools/aimux-web/examples/gen_demo_fixtures.rs @@ -58,14 +58,7 @@ fn recording( prompt: prompt.clone(), options: serde_json::to_value(&options).unwrap(), }, - provider: ProviderRecord { - provider: provider.into(), - model_id: model_id.into(), - base_url: None, - api_key_source: "none".into(), - profile: None, - provider_options: None, - }, + provider: ProviderRecord::from_model(provider, model_id), exchanges: vec![HttpExchange { step: None, attempt: 0, diff --git a/tools/aimux-web/examples/gen_fixture.rs b/tools/aimux-web/examples/gen_fixture.rs index 55e0bc558..b6b6b8b66 100644 --- a/tools/aimux-web/examples/gen_fixture.rs +++ b/tools/aimux-web/examples/gen_fixture.rs @@ -47,14 +47,7 @@ fn main() { prompt: prompt.clone(), options: serde_json::to_value(&options).unwrap(), }, - provider: ProviderRecord { - provider: "openai".into(), - model_id: "gpt-4o-mini".into(), - base_url: None, - api_key_source: "none".into(), - profile: None, - provider_options: None, - }, + provider: ProviderRecord::new("openai", "openai.chat", "gpt-4o-mini"), exchanges: vec![HttpExchange { step: None, attempt: 0, diff --git a/tools/aimux-web/src/api/replay.rs b/tools/aimux-web/src/api/replay.rs index 776685f07..7ad0bf97a 100644 --- a/tools/aimux-web/src/api/replay.rs +++ b/tools/aimux-web/src/api/replay.rs @@ -60,29 +60,15 @@ async fn run_inner(state: AppState, req: ReplayRequest) -> Result { - wire::resolve_api_key(Some(s), &provider_name, &state.keys, state.loopback)? - } - "none" => wire::resolve_api_key(None, &provider_name, &state.keys, state.loopback)?, - _ => wire::resolve_api_key( - req.api_key.as_deref(), - &provider_name, - &state.keys, - state.loopback, - ) - .map_err(|_| { - AiMuxError::InvalidArgument( - "this recording has no env key source — pass api_key=\"env:VAR\" or save the \ - provider key in Settings to replay" - .into(), - ) - })?, - }; + // Resolve the key for provider rebuild: the request spec first, then the + // Settings key store; otherwise the provider's registered env var is read + // (RFC-0029 §5.5). The recording stores no credential source. + let key = wire::resolve_api_key( + req.api_key.as_deref(), + &recording.provider.provider_id, + &state.keys, + state.loopback, + )?; let model = aimux_providers::rebuild_provider(&recording.provider, key.as_deref())?; @@ -135,8 +121,14 @@ fn mock_load_inner(state: AppState, req: MockLoadRequest) -> Result, body: String) -> Respon count += 1; } Err(e) => { - return err_response(AiMuxError::JsonParse(format!( - "import line {}: {e}", - idx + 1 - ))); + let msg = format!("import line {}: {e}", idx + 1); + return err_response(if e.classify() == serde_json::error::Category::Data { + AiMuxError::InvalidArgument(msg) + } else { + AiMuxError::JsonParse(msg) + }); } } } diff --git a/tools/aimux-web/src/model_builder.rs b/tools/aimux-web/src/model_builder.rs index 3823e10ff..73ba37c95 100644 --- a/tools/aimux-web/src/model_builder.rs +++ b/tools/aimux-web/src/model_builder.rs @@ -58,7 +58,7 @@ pub fn build_model( } let model = aimux_providers::provider(provider, api_key, model_id, Some(options)) .map_err(|e| AiMuxError::InvalidArgument(format!("provider '{provider}': {e}")))?; - Ok(Arc::from(model)) + Ok(model) } } } diff --git a/tools/aimux-web/src/wire.rs b/tools/aimux-web/src/wire.rs index 0c97f2ad6..b8c86fdd6 100644 --- a/tools/aimux-web/src/wire.rs +++ b/tools/aimux-web/src/wire.rs @@ -83,8 +83,6 @@ pub struct WireOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub headers: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub body_overrides: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub include_raw_chunks: Option, @@ -273,7 +271,6 @@ pub fn to_generate_options( .map(|ts| ts.iter().map(to_function_tool).collect()), response_format, headers: o.headers.clone(), - body_overrides: o.body_overrides.clone(), max_retries: o.max_retries, session_id: session_id.map(str::to_string), include_raw_chunks: o.include_raw_chunks, diff --git a/tools/aimux-web/web/src/types/generated/WireOptions.ts b/tools/aimux-web/web/src/types/generated/WireOptions.ts index 3100d9b47..7d0ae849c 100644 --- a/tools/aimux-web/web/src/types/generated/WireOptions.ts +++ b/tools/aimux-web/web/src/types/generated/WireOptions.ts @@ -6,4 +6,4 @@ export type WireOptions = { temperature?: number | null, max_output_tokens?: num /** * `"text"` or `{"json": …}` — passed through as `ResponseFormat`. */ -response_format?: JsonValue | null, headers?: { [key in string]: string } | null, body_overrides?: JsonValue | null, max_retries?: number | null, include_raw_chunks?: boolean | null, }; +response_format?: JsonValue | null, headers?: { [key in string]: string } | null, max_retries?: number | null, include_raw_chunks?: boolean | null, }; diff --git a/tools/aimux-web/web/src/views/Playground.vue b/tools/aimux-web/web/src/views/Playground.vue index c60e4b0d5..08a95cdb1 100644 --- a/tools/aimux-web/web/src/views/Playground.vue +++ b/tools/aimux-web/web/src/views/Playground.vue @@ -34,7 +34,6 @@ const maxTokens = ref(1024) const selectedTools = ref([]) const responseFormat = ref('text') const headersJson = ref('{}') -const overridesJson = ref('{}') const suggested = computed(() => store.suggestedModels[provider.value] ?? []) const modelOptions = computed(() => @@ -134,7 +133,6 @@ async function send() { tools, response_format: responseFormat.value === 'json' ? { json: {} } : null, headers: safeJson(headersJson.value), - body_overrides: safeJson(overridesJson.value), }, session_id: sessionId.value, messages: wireMessages.value, @@ -327,10 +325,6 @@ function openTrace(item: ChatItem) {