feat(platform): providerOptions passthrough + provider hardening#1694
Conversation
📝 WalkthroughWalkthroughThis pull request implements a comprehensive provider options feature that allows operators to pass free-form JSON configuration to LLM providers at both provider and per-model levels. The change includes schema validation with deny-lists to prevent unsafe overwrites, access controls requiring developer settings capability, merging semantics for provider/model-level options, and propagation across all LLM call sites (streaming, retries, recovery). The UI adds a JSON editor component for both provider and model-level configuration with validation error display, documentation explains merge rules and per-provider examples, and tests verify schema validation and utility functions. All costs are now rounded consistently, agent token limits moved from agent-level to Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
services/platform/convex/providers/file_actions.ts (1)
386-405:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAlways clear the deleted-secret cache, and reuse the existing errno guard.
If the secrets unlink succeeds but
unlink(filePath)throws,invalidateSecretsCache(secretsPath)never runs and the deleted credential stays resident in memory longer than intended. This block already hasisErrnoCode(), so theas NodeJS.ErrnoExceptionassertions and lint suppressions can go away at the same time.Proposed fix
- await unlink(secretsPath).catch((err: unknown) => { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Node.js errors always have .code - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - }); - await unlink(filePath).catch((err: unknown) => { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Node.js errors always have .code - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - }); - // Drop any cached plaintext for the deleted secrets file. Without this, - // the in-memory cache holds rotated/deleted credentials until process - // restart (next read would ENOENT before reaching the cache, so this is - // a memory-residency concern, not a stale-serve risk). - invalidateSecretsCache(secretsPath); + try { + await unlink(secretsPath).catch((err: unknown) => { + if (!isErrnoCode(err, 'ENOENT')) throw err; + }); + await unlink(filePath).catch((err: unknown) => { + if (!isErrnoCode(err, 'ENOENT')) throw err; + }); + } finally { + // Drop any cached plaintext for the deleted secrets file even when the + // public-config unlink fails after the secret was already removed. + invalidateSecretsCache(secretsPath); + }As per coding guidelines "Never use
as,any, orunknownin TypeScript — use type guards, generics, discriminated unions, orneverinstead."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/platform/convex/providers/file_actions.ts` around lines 386 - 405, The delete sequence can skip invalidateSecretsCache(secretsPath) if unlink(filePath) throws; change the flow so invalidateSecretsCache(secretsPath) always runs (e.g., place it in a finally-equivalent path or call it after both unlink attempts regardless of errors), and consolidate the errno checks by using the existing isErrnoCode() guard in both catches to swallow only ENOENT and rethrow other errors; remove the NodeJS.ErrnoException casts and oxlint-disable comments. Ensure you update the catch handlers around unlink(secretsPath) and unlink(filePath) to use isErrnoCode(err) && err.code === 'ENOENT' for the guard and then call invalidateSecretsCache(secretsPath) unconditionally.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/en/self-hosted/configuration/providers.md`:
- Around line 131-139: The example uses a deny-listed field
providerOptions.metadata; update the example to remove metadata and show a
supported option instead (e.g., place the tag into providerOptions.headers or
another allowed key). Locate the example that defines providerOptions and
replace the metadata object with an allowed field (reference providerOptions and
metadata) so the snippet validates against the runtime/schema and still
demonstrates passing observability tags via headers or the documented
passthrough option.
In
`@services/platform/app/features/settings/providers/components/provider-options-editor.tsx`:
- Around line 286-288: Replace the hardcoded "Example:" label with a translated
string using the component's i18n hook (the same translation system used
elsewhere in provider-options-editor.tsx). Locate the Text element rendering
{`Example: ${PROVIDER_OPTIONS_PLACEHOLDER.replace(/\s+/g, ' ')}`} and change it
to use the translation function (e.g., t('...') or similar) for the "Example"
prefix, then concatenate or format it with
PROVIDER_OPTIONS_PLACEHOLDER.replace(/\s+/g, ' ') so the prefix is localized;
add the new key to the translation files with the appropriate copy.
- Around line 117-145: handleSave currently treats parseable JSON that is null,
an array, or a scalar as parsed = undefined which can wipe providerOptions;
instead, after JSON.parse(trimmed) in handleSave, validate that obj is a
non-empty plain object and if it is null/array/not an object/empty, call toast
with a validation error (use copy.saveError or add a new copy string) and return
(same as the catch path) so the save is aborted rather than clearing
providerOptions; keep the existing empty-string branch that allows parsed =
undefined for explicit blanking, but do not allow parseable non-object JSON to
fall through to parsed = undefined.
In `@services/platform/convex/providers/file_actions.ts`:
- Around line 94-123: The probe helpers (runProbe, runTranscriptionProbe,
fetchProviderModelIds) currently call raw fetch() after checkProviderHostPolicy,
which only inspects the literal hostname and can be bypassed by DNS resolving to
private/metadata IPs; replace those raw fetch() calls with the existing
safeFetch() (or refactor those helpers to delegate into a single
safeFetch-backed path) so network requests are made via safeFetch and thus
subject to DNS-resolved address checks and SSRF protections (ensure you still
call checkProviderHostPolicy where appropriate and keep fetchProviderModels
behavior consistent).
In `@services/platform/convex/providers/resolve_model.ts`:
- Around line 231-234: The current wire-log redaction only blanks `messages` and
`input`; update the redaction logic used where the REDACTION comment appears so
that it also blanks or omits `system`, `tools`, `tool_choice`, `metadata`,
`prompt_cache_key`, `user`, and `prediction` (and any prompt fields) before
emitting debug logs; locate the redaction routine in resolve_model.ts (the place
that currently mentions REDACTION and the code around the `messages`/`input`
blanking at the top and the similar block later covering lines ~262-273) and
extend that routine to sanitize those additional fields consistently so
sensitive PII/secrets are never logged in debug wire output.
In `@services/platform/messages/de.json`:
- Around line 1037-1047: The translation uses mixed "Provider" and "Anbieter"
terms; update the new keys to use "Anbieter" for consistency: change
providerLevelTitle and modelLevelTitle to "Anbieter-Optionen", change
notConfigured to "Nicht konfiguriert — verwendet Anbieter-/Laufzeit-Standards.",
change save to "Anbieter-Optionen speichern", saveSuccess to "Anbieter-Optionen
gespeichert", saveError to "Anbieter-Optionen konnten nicht gespeichert werden",
and invalidJson to "Anbieter-Optionen sind kein gültiges JSON" so they match
existing terminology (refer to keys providerLevelTitle, modelLevelTitle,
notConfigured, save, saveSuccess, saveError, invalidJson).
In `@services/platform/messages/en.json`:
- Around line 1036-1046: The help text under providerOptions (keys
providerLevelDescription and modelLevelHelp) is very long and multi-paragraph;
verify the UI component that renders these strings (tooltips, popovers, or
collapsible help) can display multi-paragraph content without clipping or layout
issues, and if not, split these strings into shorter i18n keys (e.g.,
providerLevelDescription_intro, providerLevelDescription_examples,
modelLevelHelp_intro, modelLevelHelp_examples) or convert them to content that
the UI renders as HTML/markdown blocks; update the consuming component to use
the new keys or render multi-paragraph content safely so the advanced help
displays correctly.
- Line 1047: Update the "invalidJson" message value to remove the
singular/plural mismatch—replace the current text for the invalidJson key
("Provider options is not valid JSON") with a grammatically consistent phrasing
such as "Provider options are not valid JSON" or "Provider options must be valid
JSON" (use the form that matches the project's existing message style
elsewhere); locate the invalidJson entry in services/platform/messages/en.json
and change only the string value for that key.
---
Outside diff comments:
In `@services/platform/convex/providers/file_actions.ts`:
- Around line 386-405: The delete sequence can skip
invalidateSecretsCache(secretsPath) if unlink(filePath) throws; change the flow
so invalidateSecretsCache(secretsPath) always runs (e.g., place it in a
finally-equivalent path or call it after both unlink attempts regardless of
errors), and consolidate the errno checks by using the existing isErrnoCode()
guard in both catches to swallow only ENOENT and rethrow other errors; remove
the NodeJS.ErrnoException casts and oxlint-disable comments. Ensure you update
the catch handlers around unlink(secretsPath) and unlink(filePath) to use
isErrnoCode(err) && err.code === 'ENOENT' for the guard and then call
invalidateSecretsCache(secretsPath) unconditionally.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5f6911b9-9977-4a51-a30c-cb0efc3a5e90
⛔ Files ignored due to path filters (1)
services/platform/convex/_generated/api.d.tsis excluded by!**/_generated/**
📒 Files selected for processing (38)
docs/en/self-hosted/configuration/providers.mdexamples/providers/openrouter.jsonservices/platform/app/features/settings/providers/components/provider-add-panel.tsxservices/platform/app/features/settings/providers/components/provider-options-editor.tsxservices/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsxservices/platform/convex/agent_tools/files/helpers/analyze_image.tsservices/platform/convex/agent_tools/files/helpers/analyze_image_by_url.tsservices/platform/convex/agent_tools/files/helpers/vision_agent.tsservices/platform/convex/agents/image_generation/run_image_generation.tsservices/platform/convex/agents/translate_fields.tsservices/platform/convex/conversations/actions.tsservices/platform/convex/conversations/improve_message.tsservices/platform/convex/governance/cost_estimation.tsservices/platform/convex/lib/agent_chat/internal_actions.tsservices/platform/convex/lib/agent_response/generate_response.tsservices/platform/convex/lib/agent_response/types.tsservices/platform/convex/lib/create_agent_config.test.tsservices/platform/convex/lib/create_agent_config.tsservices/platform/convex/lib/provider_options.test.tsservices/platform/convex/lib/provider_options.tsservices/platform/convex/lib/summarization/auto_summarize.tsservices/platform/convex/lib/summarization/internal_actions.tsservices/platform/convex/lib/summarize_context.tsservices/platform/convex/openai_compat/internal_actions.tsservices/platform/convex/prompts/generate_title.tsservices/platform/convex/providers/auth.tsservices/platform/convex/providers/failover.tsservices/platform/convex/providers/file_actions.tsservices/platform/convex/providers/resolve_model.tsservices/platform/convex/threads/generate_thread_title.tsservices/platform/convex/workflow_engine/helpers/nodes/llm/execute_agent_with_tools.tsservices/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.tsservices/platform/convex/workflows/triggers/actions.tsservices/platform/lib/shared/schemas/providers.test.tsservices/platform/lib/shared/schemas/providers.tsservices/platform/messages/de.jsonservices/platform/messages/en.jsonservices/platform/messages/fr.json
| ```json | ||
| // Vercel AI Gateway — primary routing happens via the model-ID prefix | ||
| // (e.g. "anthropic/claude-3.5") and HTTP headers like `ai-gateway-order`. | ||
| // Body-level passthrough is mostly useful for observability tags Vercel | ||
| // surfaces in its dashboard. | ||
| "providerOptions": { | ||
| "metadata": { "tale_agent": "support" } | ||
| } | ||
| ``` |
There was a problem hiding this comment.
The Vercel AI Gateway example uses a deny-listed field.
This example shows providerOptions.metadata, but metadata is explicitly blocked by the new schema/runtime strip. Following the docs as written will make the provider config fail validation instead of demonstrating a supported gateway option.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/en/self-hosted/configuration/providers.md` around lines 131 - 139, The
example uses a deny-listed field providerOptions.metadata; update the example to
remove metadata and show a supported option instead (e.g., place the tag into
providerOptions.headers or another allowed key). Locate the example that defines
providerOptions and replace the metadata object with an allowed field (reference
providerOptions and metadata) so the snippet validates against the
runtime/schema and still demonstrates passing observability tags via headers or
the documented passthrough option.
| const handleSave = async () => { | ||
| let parsed: Record<string, unknown> | undefined; | ||
| const trimmed = draft.trim(); | ||
| if (trimmed === '') { | ||
| parsed = undefined; | ||
| } else { | ||
| try { | ||
| const obj: unknown = JSON.parse(trimmed); | ||
| if ( | ||
| obj == null || | ||
| typeof obj !== 'object' || | ||
| Array.isArray(obj) || | ||
| Object.keys(obj).length === 0 | ||
| ) { | ||
| parsed = undefined; | ||
| } else { | ||
| // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- runtime checks above narrow `obj` to a non-null, non-array plain object; TS can't track the narrowing across JSON.parse | ||
| parsed = obj as Record<string, unknown>; | ||
| } | ||
| } catch (err) { | ||
| toast({ | ||
| variant: 'destructive', | ||
| title: copy.saveError, | ||
| description: err instanceof Error ? err.message : String(err), | ||
| }); | ||
| return; | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Reject parseable invalid JSON instead of silently clearing the config.
Right now null, arrays, or scalar JSON values fall through to parsed = undefined, so clicking Save can wipe an existing providerOptions block instead of surfacing a validation error. That turns a malformed edit into data loss.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@services/platform/app/features/settings/providers/components/provider-options-editor.tsx`
around lines 117 - 145, handleSave currently treats parseable JSON that is null,
an array, or a scalar as parsed = undefined which can wipe providerOptions;
instead, after JSON.parse(trimmed) in handleSave, validate that obj is a
non-empty plain object and if it is null/array/not an object/empty, call toast
with a validation error (use copy.saveError or add a new copy string) and return
(same as the catch path) so the save is aborted rather than clearing
providerOptions; keep the existing empty-string branch that allows parsed =
undefined for explicit blanking, but do not allow parseable non-object JSON to
fall through to parsed = undefined.
| <Text className="text-muted-foreground text-[12px]"> | ||
| {`Example: ${PROVIDER_OPTIONS_PLACEHOLDER.replace(/\s+/g, ' ')}`} | ||
| </Text> |
There was a problem hiding this comment.
Move the Example: label into i18n.
This string is rendered directly in JSX, so it will stay English even when the rest of the editor is translated.
As per coding guidelines: "No hardcoded user-facing strings in React — always use the translation hook."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@services/platform/app/features/settings/providers/components/provider-options-editor.tsx`
around lines 286 - 288, Replace the hardcoded "Example:" label with a translated
string using the component's i18n hook (the same translation system used
elsewhere in provider-options-editor.tsx). Locate the Text element rendering
{`Example: ${PROVIDER_OPTIONS_PLACEHOLDER.replace(/\s+/g, ' ')}`} and change it
to use the translation function (e.g., t('...') or similar) for the "Example"
prefix, then concatenate or format it with
PROVIDER_OPTIONS_PLACEHOLDER.replace(/\s+/g, ' ') so the prefix is localized;
add the new key to the translation files with the appropriate copy.
| let providerOptions: Record<string, unknown> | undefined; | ||
| const trimmedProviderOptions = form.providerOptionsJson.trim(); | ||
| if (trimmedProviderOptions) { | ||
| try { | ||
| const parsed: unknown = JSON.parse(trimmedProviderOptions); | ||
| if ( | ||
| parsed != null && | ||
| typeof parsed === 'object' && | ||
| !Array.isArray(parsed) | ||
| ) { | ||
| // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- runtime checks above narrow `parsed` to a non-null, non-array plain object; TS can't track the narrowing across JSON.parse | ||
| const obj = parsed as Record<string, unknown>; | ||
| if (Object.keys(obj).length > 0) { | ||
| providerOptions = obj; | ||
| } | ||
| } | ||
| } catch (parseErr) { | ||
| toast({ | ||
| title: t('providers.providerOptions.invalidJson'), | ||
| description: | ||
| parseErr instanceof Error ? parseErr.message : String(parseErr), | ||
| variant: 'destructive', | ||
| }); | ||
| return; | ||
| } | ||
| } |
There was a problem hiding this comment.
Don't treat non-object JSON as "clear the field".
If a user enters parseable but invalid JSON here—null, [], or a string literal—the submit path just leaves providerOptions undefined and saves the model. That silently drops any existing override instead of telling the user the payload must be an object.
| function checkProviderHostPolicy(rawUrl: string): URL { | ||
| let parsed: URL; | ||
| try { | ||
| parsed = new URL(rawUrl); | ||
| } catch { | ||
| throw new ConvexError({ | ||
| code: 'INVALID_URL', | ||
| message: `Invalid URL: ${rawUrl}`, | ||
| }); | ||
| } | ||
| const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); | ||
| if (BLOCKED_METADATA_HOSTS.has(host)) { | ||
| throw new ConvexError({ | ||
| code: 'BLOCKED_HOST', | ||
| message: `Host "${host}" is blocked (cloud metadata endpoint).`, | ||
| }); | ||
| } | ||
| if ( | ||
| isPrivateIp(host) && | ||
| process.env.TALE_ALLOW_PRIVATE_PROVIDER_HOSTS !== '1' | ||
| ) { | ||
| throw new ConvexError({ | ||
| code: 'PRIVATE_HOST_BLOCKED', | ||
| message: | ||
| `Host "${host}" is a private/loopback address and is blocked. ` + | ||
| 'Set TALE_ALLOW_PRIVATE_PROVIDER_HOSTS=1 in the platform process env to ' + | ||
| 'enable self-hosted backends like Ollama on localhost.', | ||
| }); | ||
| } | ||
| return parsed; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== services/platform/convex/lib/http/safe_fetch.ts =="
SAFE_FETCH_FILE=$(fd '^safe_fetch\.ts$' services/platform/convex/lib/http)
sed -n '1,260p' "$SAFE_FETCH_FILE"
echo
echo "== services/platform/convex/providers/file_actions.ts SSRF-related call sites =="
sed -n '94,123p;828,923p;985,1095p;1143,1182p;1252,1361p' services/platform/convex/providers/file_actions.tsRepository: tale-project/tale
Length of output: 20841
Use safeFetch in testProviderConnection probes to prevent SSRF via DNS-resolved private addresses.
The checkProviderHostPolicy() check happens on the literal hostname string before DNS resolution. The four probe helpers (runProbe, runTranscriptionProbe, fetchProviderModelIds) then issue raw fetch() calls that resolve the hostname at the network level. A hostname like metadata.internal.example.com would pass the literal-hostname check in isPrivateIp() (no match for localhost / .local / IP literals) but resolve to a cloud metadata endpoint (169.254.169.254) or RFC1918 range (10.0.0.0/8). This bypasses the SSRF gate for any authenticated developer-settings user.
Fix: Replace the raw fetch() calls in runProbe(), runTranscriptionProbe(), and fetchProviderModelIds() with safeFetch() calls, or move these helpers' implementations into a single safeFetch-backed path. The fetchProviderModels action is already safe (uses safeFetch after checkProviderHostPolicy).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/platform/convex/providers/file_actions.ts` around lines 94 - 123,
The probe helpers (runProbe, runTranscriptionProbe, fetchProviderModelIds)
currently call raw fetch() after checkProviderHostPolicy, which only inspects
the literal hostname and can be bypassed by DNS resolving to private/metadata
IPs; replace those raw fetch() calls with the existing safeFetch() (or refactor
those helpers to delegate into a single safeFetch-backed path) so network
requests are made via safeFetch and thus subject to DNS-resolved address checks
and SSRF protections (ensure you still call checkProviderHostPolicy where
appropriate and keep fetchProviderModels behavior consistent).
| * REDACTION — only `messages` and `input` are blanked. Other body fields | ||
| * including `system`, `tools`, `tool_choice`, `metadata`, `prompt_cache_key`, | ||
| * `user`, `prediction` are logged verbatim. Use this flag for development; | ||
| * not appropriate for production logs. |
There was a problem hiding this comment.
Broaden wire-log redaction to avoid sensitive payload leakage.
Line 231 and Line 262 currently redact only messages/input, but prompts and metadata can still contain PII/secrets and are logged verbatim when debug mode is enabled.
🔧 Suggested patch
+const DEBUG_REDACT_KEYS = new Set([
+ 'messages',
+ 'input',
+ 'prompt',
+ 'system',
+ 'metadata',
+ 'tools',
+ 'tool_choice',
+ 'user',
+ 'prediction',
+]);
+
function createDebugFetch(providerName: string): FetchFn | undefined {
if (process.env.TALE_DEBUG_LLM_WIRE !== '1') return undefined;
return async (input, init) => {
@@
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const entries: Array<[string, unknown]> = [];
for (const [k, v] of Object.entries(parsed)) {
- entries.push(
- k === 'messages' || k === 'input' ? [k, '[REDACTED]'] : [k, v],
- );
+ entries.push(DEBUG_REDACT_KEYS.has(k) ? [k, '[REDACTED]'] : [k, v]);
}
redacted = Object.fromEntries(entries);
}Also applies to: 262-273
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/platform/convex/providers/resolve_model.ts` around lines 231 - 234,
The current wire-log redaction only blanks `messages` and `input`; update the
redaction logic used where the REDACTION comment appears so that it also blanks
or omits `system`, `tools`, `tool_choice`, `metadata`, `prompt_cache_key`,
`user`, and `prediction` (and any prompt fields) before emitting debug logs;
locate the redaction routine in resolve_model.ts (the place that currently
mentions REDACTION and the code around the `messages`/`input` blanking at the
top and the similar block later covering lines ~262-273) and extend that routine
to sanitize those additional fields consistently so sensitive PII/secrets are
never logged in debug wire output.
| "providerLevelTitle": "Erweitert — Provider-Optionen", | ||
| "providerLevelDescription": "Freies JSON, das wortgetreu als zusätzliche Request-Body-Felder an jedes Modell dieses Anbieters weitergeleitet wird. Die Struktur wird vom Upstream-API vorgegeben — Tale interpretiert diese Felder nicht, sondern reicht sie nur durch.\n\nWelche Knöpfe verfügbar sind, hängt davon ab, ob es sich beim Upstream um ein Routing-Gateway oder einen direkten Inferenz-Anbieter handelt:\n\nGateways aggregieren mehrere Backends und exponieren Routing-Steuerungen:\n• OpenRouter — Routing-Optionen unter einem Top-Level-Schlüssel \"provider\" (Quantisierung, Fallback-Richtlinie, erlaubte Backends usw. wählen):\n { \"provider\": { \"quantizations\": [\"fp8\"], \"allow_fallbacks\": false } }\n• Vercel AI Gateway — routet primär über das Modell-ID-Präfix und HTTP-Header; Body-Level-Passthrough ist auf Observability-Felder wie \"metadata\" beschränkt:\n { \"metadata\": { \"tale_agent\": \"support\" } }\n\nDirekte Anbieter hosten ihre eigenen Modelle — es gibt keine Routing-Schicht und kein \"quantizations\"-Feld; die Präzision ist beim Deployment festgelegt. Ihre Passthrough-Felder sind Modellverhaltens-Knöpfe auf oberster Body-Ebene:\n• OpenAI:\n { \"service_tier\": \"priority\", \"parallel_tool_calls\": false }\n• Together AI:\n { \"safety_model\": \"Meta-Llama-Guard-3-8B\", \"repetition_penalty\": 1.1 }\n\nFür die genauen Feldnamen und akzeptierten Werte die API-Dokumentation des jeweiligen Anbieters konsultieren.", | ||
| "modelLevelTitle": "Erweitert — Provider-Optionen", | ||
| "modelLevelDescription": "Freies JSON, das mit den Anbieter-Standards zusammengeführt und als zusätzliche Request-Body-Felder weitergeleitet wird. Die Struktur folgt dem Upstream-API und unterscheidet sich je nach Anbieter — Tale reicht das JSON wortgetreu durch, ohne es zu interpretieren.", | ||
| "modelLevelHelp": "Beispiele — Gateways und direkte Anbieter exponieren unterschiedliche Arten von Knöpfen:\n• OpenRouter (Gateway): { \"provider\": { \"quantizations\": [\"fp8\"] } } — ein Backend nach Quantisierung anpinnen\n• Vercel AI Gateway (Gateway): { \"metadata\": { \"tale_agent\": \"support\" } } — Observability-Tag (Routing meist über Header oder Modell-Präfix)\n• OpenAI (direkt): { \"service_tier\": \"priority\" } — eine SLA-Stufe wählen\n• Together AI (direkt): { \"safety_model\": \"Meta-Llama-Guard-3-8B\" } — Moderation anwenden\n\nDirekte Anbieter exponieren Quantisierung nicht als Request-Feld — stattdessen eine andere Modell-ID wählen. Schlüssel wie model / messages / max_tokens / temperature werden abgelehnt — diese auf Agent-Ebene konfigurieren. Leere Eingabe entfernt die Überschreibung.", | ||
| "notConfigured": "Nicht konfiguriert — verwendet Provider-/Laufzeit-Standards.", | ||
| "indicator": "Eigene Optionen", | ||
| "save": "Provider-Optionen speichern", | ||
| "saveSuccess": "Provider-Optionen gespeichert", | ||
| "saveError": "Provider-Optionen konnten nicht gespeichert werden", | ||
| "invalidJson": "Provider-Optionen sind kein gültiges JSON" |
There was a problem hiding this comment.
Inconsistent terminology: "Provider" vs "Anbieter".
The new providerOptions namespace uses "Provider-Optionen" (English-German hybrid), but the rest of this file consistently uses "Anbieter" (pure German) for "provider":
- Line 203:
"providers": "KI-Anbieter" - Line 996:
"title": "Anbieter" - Line 1020:
"Anbietername" - Line 1035 (just added):
"Anbieter-Konfiguration"
Affected keys:
- 1037:
providerLevelTitle→ suggest "Anbieter-Optionen" - 1039:
modelLevelTitle→ suggest "Anbieter-Optionen" - 1042:
notConfigured→ suggest "Anbieter-/Laufzeit-Standards" - 1044:
save→ suggest "Anbieter-Optionen speichern" - 1045:
saveSuccess→ suggest "Anbieter-Optionen gespeichert" - 1046:
saveError→ suggest "...Anbieter-Optionen..." - 1047:
invalidJson→ suggest "Anbieter-Optionen sind..."
Impact: Users see inconsistent terminology in the UI, which may reduce clarity and searchability.
Suggested consistency fix
- "providerLevelTitle": "Erweitert — Provider-Optionen",
+ "providerLevelTitle": "Erweitert — Anbieter-Optionen",
"providerLevelDescription": "Freies JSON, das wortgetreu als zusätzliche Request-Body-Felder an jedes Modell dieses Anbieters weitergeleitet wird. Die Struktur wird vom Upstream-API vorgegeben — Tale interpretiert diese Felder nicht, sondern reicht sie nur durch.\n\nWelche Knöpfe verfügbar sind, hängt davon ab, ob es sich beim Upstream um ein Routing-Gateway oder einen direkten Inferenz-Anbieter handelt:\n\nGateways aggregieren mehrere Backends und exponieren Routing-Steuerungen:\n• OpenRouter — Routing-Optionen unter einem Top-Level-Schlüssel \"provider\" (Quantisierung, Fallback-Richtlinie, erlaubte Backends usw. wählen):\n { \"provider\": { \"quantizations\": [\"fp8\"], \"allow_fallbacks\": false } }\n• Vercel AI Gateway — routet primär über das Modell-ID-Präfix und HTTP-Header; Body-Level-Passthrough ist auf Observability-Felder wie \"metadata\" beschränkt:\n { \"metadata\": { \"tale_agent\": \"support\" } }\n\nDirekte Anbieter hosten ihre eigenen Modelle — es gibt keine Routing-Schicht und kein \"quantizations\"-Feld; die Präzision ist beim Deployment festgelegt. Ihre Passthrough-Felder sind Modellverhaltens-Knöpfe auf oberster Body-Ebene:\n• OpenAI:\n { \"service_tier\": \"priority\", \"parallel_tool_calls\": false }\n• Together AI:\n { \"safety_model\": \"Meta-Llama-Guard-3-8B\", \"repetition_penalty\": 1.1 }\n\nFür die genauen Feldnamen und akzeptierten Werte die API-Dokumentation des jeweiligen Anbieters konsultieren.",
- "modelLevelTitle": "Erweitert — Provider-Optionen",
+ "modelLevelTitle": "Erweitert — Anbieter-Optionen",
"modelLevelDescription": "Freies JSON, das mit den Anbieter-Standards zusammengeführt und als zusätzliche Request-Body-Felder weitergeleitet wird. Die Struktur folgt dem Upstream-API und unterscheidet sich je nach Anbieter — Tale reicht das JSON wortgetreu durch, ohne es zu interpretieren.",
"modelLevelHelp": "Beispiele — Gateways und direkte Anbieter exponieren unterschiedliche Arten von Knöpfen:\n• OpenRouter (Gateway): { \"provider\": { \"quantizations\": [\"fp8\"] } } — ein Backend nach Quantisierung anpinnen\n• Vercel AI Gateway (Gateway): { \"metadata\": { \"tale_agent\": \"support\" } } — Observability-Tag (Routing meist über Header oder Modell-Präfix)\n• OpenAI (direkt): { \"service_tier\": \"priority\" } — eine SLA-Stufe wählen\n• Together AI (direkt): { \"safety_model\": \"Meta-Llama-Guard-3-8B\" } — Moderation anwenden\n\nDirekte Anbieter exponieren Quantisierung nicht als Request-Feld — stattdessen eine andere Modell-ID wählen. Schlüssel wie model / messages / max_tokens / temperature werden abgelehnt — diese auf Agent-Ebene konfigurieren. Leere Eingabe entfernt die Überschreibung.",
- "notConfigured": "Nicht konfiguriert — verwendet Provider-/Laufzeit-Standards.",
+ "notConfigured": "Nicht konfiguriert — verwendet Anbieter-/Laufzeit-Standards.",
"indicator": "Eigene Optionen",
- "save": "Provider-Optionen speichern",
+ "save": "Anbieter-Optionen speichern",
- "saveSuccess": "Provider-Optionen gespeichert",
+ "saveSuccess": "Anbieter-Optionen gespeichert",
- "saveError": "Provider-Optionen konnten nicht gespeichert werden",
+ "saveError": "Anbieter-Optionen konnten nicht gespeichert werden",
- "invalidJson": "Provider-Optionen sind kein gültiges JSON"
+ "invalidJson": "Anbieter-Optionen sind kein gültiges JSON"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "providerLevelTitle": "Erweitert — Provider-Optionen", | |
| "providerLevelDescription": "Freies JSON, das wortgetreu als zusätzliche Request-Body-Felder an jedes Modell dieses Anbieters weitergeleitet wird. Die Struktur wird vom Upstream-API vorgegeben — Tale interpretiert diese Felder nicht, sondern reicht sie nur durch.\n\nWelche Knöpfe verfügbar sind, hängt davon ab, ob es sich beim Upstream um ein Routing-Gateway oder einen direkten Inferenz-Anbieter handelt:\n\nGateways aggregieren mehrere Backends und exponieren Routing-Steuerungen:\n• OpenRouter — Routing-Optionen unter einem Top-Level-Schlüssel \"provider\" (Quantisierung, Fallback-Richtlinie, erlaubte Backends usw. wählen):\n { \"provider\": { \"quantizations\": [\"fp8\"], \"allow_fallbacks\": false } }\n• Vercel AI Gateway — routet primär über das Modell-ID-Präfix und HTTP-Header; Body-Level-Passthrough ist auf Observability-Felder wie \"metadata\" beschränkt:\n { \"metadata\": { \"tale_agent\": \"support\" } }\n\nDirekte Anbieter hosten ihre eigenen Modelle — es gibt keine Routing-Schicht und kein \"quantizations\"-Feld; die Präzision ist beim Deployment festgelegt. Ihre Passthrough-Felder sind Modellverhaltens-Knöpfe auf oberster Body-Ebene:\n• OpenAI:\n { \"service_tier\": \"priority\", \"parallel_tool_calls\": false }\n• Together AI:\n { \"safety_model\": \"Meta-Llama-Guard-3-8B\", \"repetition_penalty\": 1.1 }\n\nFür die genauen Feldnamen und akzeptierten Werte die API-Dokumentation des jeweiligen Anbieters konsultieren.", | |
| "modelLevelTitle": "Erweitert — Provider-Optionen", | |
| "modelLevelDescription": "Freies JSON, das mit den Anbieter-Standards zusammengeführt und als zusätzliche Request-Body-Felder weitergeleitet wird. Die Struktur folgt dem Upstream-API und unterscheidet sich je nach Anbieter — Tale reicht das JSON wortgetreu durch, ohne es zu interpretieren.", | |
| "modelLevelHelp": "Beispiele — Gateways und direkte Anbieter exponieren unterschiedliche Arten von Knöpfen:\n• OpenRouter (Gateway): { \"provider\": { \"quantizations\": [\"fp8\"] } } — ein Backend nach Quantisierung anpinnen\n• Vercel AI Gateway (Gateway): { \"metadata\": { \"tale_agent\": \"support\" } } — Observability-Tag (Routing meist über Header oder Modell-Präfix)\n• OpenAI (direkt): { \"service_tier\": \"priority\" } — eine SLA-Stufe wählen\n• Together AI (direkt): { \"safety_model\": \"Meta-Llama-Guard-3-8B\" } — Moderation anwenden\n\nDirekte Anbieter exponieren Quantisierung nicht als Request-Feld — stattdessen eine andere Modell-ID wählen. Schlüssel wie model / messages / max_tokens / temperature werden abgelehnt — diese auf Agent-Ebene konfigurieren. Leere Eingabe entfernt die Überschreibung.", | |
| "notConfigured": "Nicht konfiguriert — verwendet Provider-/Laufzeit-Standards.", | |
| "indicator": "Eigene Optionen", | |
| "save": "Provider-Optionen speichern", | |
| "saveSuccess": "Provider-Optionen gespeichert", | |
| "saveError": "Provider-Optionen konnten nicht gespeichert werden", | |
| "invalidJson": "Provider-Optionen sind kein gültiges JSON" | |
| "providerLevelTitle": "Erweitert — Anbieter-Optionen", | |
| "providerLevelDescription": "Freies JSON, das wortgetreu als zusätzliche Request-Body-Felder an jedes Modell dieses Anbieters weitergeleitet wird. Die Struktur wird vom Upstream-API vorgegeben — Tale interpretiert diese Felder nicht, sondern reicht sie nur durch.\n\nWelche Knöpfe verfügbar sind, hängt davon ab, ob es sich beim Upstream um ein Routing-Gateway oder einen direkten Inferenz-Anbieter handelt:\n\nGateways aggregieren mehrere Backends und exponieren Routing-Steuerungen:\n• OpenRouter — Routing-Optionen unter einem Top-Level-Schlüssel \"provider\" (Quantisierung, Fallback-Richtlinie, erlaubte Backends usw. wählen):\n { \"provider\": { \"quantizations\": [\"fp8\"], \"allow_fallbacks\": false } }\n• Vercel AI Gateway — routet primär über das Modell-ID-Präfix und HTTP-Header; Body-Level-Passthrough ist auf Observability-Felder wie \"metadata\" beschränkt:\n { \"metadata\": { \"tale_agent\": \"support\" } }\n\nDirekte Anbieter hosten ihre eigenen Modelle — es gibt keine Routing-Schicht und kein \"quantizations\"-Feld; die Präzision ist beim Deployment festgelegt. Ihre Passthrough-Felder sind Modellverhaltens-Knöpfe auf oberster Body-Ebene:\n• OpenAI:\n { \"service_tier\": \"priority\", \"parallel_tool_calls\": false }\n• Together AI:\n { \"safety_model\": \"Meta-Llama-Guard-3-8B\", \"repetition_penalty\": 1.1 }\n\nFür die genauen Feldnamen und akzeptierten Werte die API-Dokumentation des jeweiligen Anbieters konsultieren.", | |
| "modelLevelTitle": "Erweitert — Anbieter-Optionen", | |
| "modelLevelDescription": "Freies JSON, das mit den Anbieter-Standards zusammengeführt und als zusätzliche Request-Body-Felder weitergeleitet wird. Die Struktur folgt dem Upstream-API und unterscheidet sich je nach Anbieter — Tale reicht das JSON wortgetreu durch, ohne es zu interpretieren.", | |
| "modelLevelHelp": "Beispiele — Gateways und direkte Anbieter exponieren unterschiedliche Arten von Knöpfen:\n• OpenRouter (Gateway): { \"provider\": { \"quantizations\": [\"fp8\"] } } — ein Backend nach Quantisierung anpinnen\n• Vercel AI Gateway (Gateway): { \"metadata\": { \"tale_agent\": \"support\" } } — Observability-Tag (Routing meist über Header oder Modell-Präfix)\n• OpenAI (direkt): { \"service_tier\": \"priority\" } — eine SLA-Stufe wählen\n• Together AI (direkt): { \"safety_model\": \"Meta-Llama-Guard-3-8B\" } — Moderation anwenden\n\nDirekte Anbieter exponieren Quantisierung nicht als Request-Feld — stattdessen eine andere Modell-ID wählen. Schlüssel wie model / messages / max_tokens / temperature werden abgelehnt — diese auf Agent-Ebene konfigurieren. Leere Eingabe entfernt die Überschreibung.", | |
| "notConfigured": "Nicht konfiguriert — verwendet Anbieter-/Laufzeit-Standards.", | |
| "indicator": "Eigene Optionen", | |
| "save": "Anbieter-Optionen speichern", | |
| "saveSuccess": "Anbieter-Optionen gespeichert", | |
| "saveError": "Anbieter-Optionen konnten nicht gespeichert werden", | |
| "invalidJson": "Anbieter-Optionen sind kein gültiges JSON" |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/platform/messages/de.json` around lines 1037 - 1047, The translation
uses mixed "Provider" and "Anbieter" terms; update the new keys to use
"Anbieter" for consistency: change providerLevelTitle and modelLevelTitle to
"Anbieter-Optionen", change notConfigured to "Nicht konfiguriert — verwendet
Anbieter-/Laufzeit-Standards.", change save to "Anbieter-Optionen speichern",
saveSuccess to "Anbieter-Optionen gespeichert", saveError to "Anbieter-Optionen
konnten nicht gespeichert werden", and invalidJson to "Anbieter-Optionen sind
kein gültiges JSON" so they match existing terminology (refer to keys
providerLevelTitle, modelLevelTitle, notConfigured, save, saveSuccess,
saveError, invalidJson).
| "providerOptions": { | ||
| "providerLevelTitle": "Advanced — Provider Options", | ||
| "providerLevelDescription": "Free-form JSON forwarded verbatim as extra request body fields to every model on this provider. The shape is dictated by the upstream API — Tale does not interpret these fields, it just passes them through.\n\nThe available knobs depend on whether the upstream is a routing gateway or a direct inference vendor:\n\nGateways aggregate multiple backends and expose routing controls:\n• OpenRouter — routing options live under a top-level \"provider\" key (pick the quantization, fallback policy, allowed backends, etc.):\n { \"provider\": { \"quantizations\": [\"fp8\"], \"allow_fallbacks\": false } }\n• Vercel AI Gateway — primarily routes via the model-ID prefix and HTTP headers; body-level passthrough is limited to observability fields like \"metadata\":\n { \"metadata\": { \"tale_agent\": \"support\" } }\n\nDirect vendors host their own models, so there is no routing layer and no \"quantizations\" field — the precision is fixed at deploy time. Their passthrough fields are model-behavior knobs at the body's top level:\n• OpenAI:\n { \"service_tier\": \"priority\", \"parallel_tool_calls\": false }\n• Together AI:\n { \"safety_model\": \"Meta-Llama-Guard-3-8B\", \"repetition_penalty\": 1.1 }\n\nRefer to each provider's API docs for the exact field names and accepted values.", | ||
| "modelLevelTitle": "Advanced — Provider Options", | ||
| "modelLevelDescription": "Free-form JSON merged on top of the provider-level defaults and forwarded as extra request body fields. The shape mirrors the upstream API and differs per provider — Tale forwards verbatim without interpretation.", | ||
| "modelLevelHelp": "Examples — gateways and direct vendors expose different kinds of knobs:\n• OpenRouter (gateway): { \"provider\": { \"quantizations\": [\"fp8\"] } } — pin a backend by quantization\n• Vercel AI Gateway (gateway): { \"metadata\": { \"tale_agent\": \"support\" } } — observability tag (most routing is header- or model-prefix-based)\n• OpenAI (direct): { \"service_tier\": \"priority\" } — pick an SLA tier\n• Together AI (direct): { \"safety_model\": \"Meta-Llama-Guard-3-8B\" } — apply moderation\n\nDirect vendors don't expose quantization as a request field — pick a different model ID instead. Keys like model / messages / max_tokens / temperature are rejected — set those at the agent level. Empty input clears the override.", | ||
| "notConfigured": "Not configured — using provider/runtime defaults.", | ||
| "indicator": "Custom options", | ||
| "save": "Save provider options", | ||
| "saveSuccess": "Provider options saved", | ||
| "saveError": "Failed to save provider options", |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | 💤 Low value
Comprehensive help text for advanced feature.
The providerOptions descriptions are thorough and technically accurate, with concrete examples for different provider types (gateways vs. direct vendors). The content clearly explains the passthrough behavior and provides actionable examples.
Optional consideration: The providerLevelDescription (~800 characters) and modelLevelHelp (~500 characters) are notably longer than typical i18n strings. This appears intentional for an advanced feature, but verify that your UI can comfortably display multi-paragraph help text in the context where these strings are used (likely tooltips or collapsible sections).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/platform/messages/en.json` around lines 1036 - 1046, The help text
under providerOptions (keys providerLevelDescription and modelLevelHelp) is very
long and multi-paragraph; verify the UI component that renders these strings
(tooltips, popovers, or collapsible help) can display multi-paragraph content
without clipping or layout issues, and if not, split these strings into shorter
i18n keys (e.g., providerLevelDescription_intro,
providerLevelDescription_examples, modelLevelHelp_intro,
modelLevelHelp_examples) or convert them to content that the UI renders as
HTML/markdown blocks; update the consuming component to use the new keys or
render multi-paragraph content safely so the advanced help displays correctly.
| "save": "Save provider options", | ||
| "saveSuccess": "Provider options saved", | ||
| "saveError": "Failed to save provider options", | ||
| "invalidJson": "Provider options is not valid JSON" |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Rephrase to avoid singular/plural ambiguity.
The phrasing "Provider options is not valid JSON" creates a subtle grammatical mismatch—"options" reads as plural, but "is" is singular. While the intent is clear (treating the configuration as a singular entity), it may sound awkward to native speakers.
Consider rewording to match the pattern used elsewhere in the file:
✏️ Suggested alternatives
Option 1 (matches existing pattern at line 124):
- "invalidJson": "Provider options is not valid JSON"
+ "invalidJson": "Invalid JSON in provider options"Option 2 (simpler):
- "invalidJson": "Provider options is not valid JSON"
+ "invalidJson": "Provider options: invalid JSON"Option 3 (explicit):
- "invalidJson": "Provider options is not valid JSON"
+ "invalidJson": "Invalid provider options JSON"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "invalidJson": "Provider options is not valid JSON" | |
| "invalidJson": "Invalid JSON in provider options" |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/platform/messages/en.json` at line 1047, Update the "invalidJson"
message value to remove the singular/plural mismatch—replace the current text
for the invalidJson key ("Provider options is not valid JSON") with a
grammatically consistent phrasing such as "Provider options are not valid JSON"
or "Provider options must be valid JSON" (use the form that matches the
project's existing message style elsewhere); locate the invalidJson entry in
services/platform/messages/en.json and change only the string value for that
key.
…/Qwen/Gemma
Adds an extensible providerOptions field at provider and model level in
providers/*.json (and the dashboard) that forwards verbatim into the LLM
request body — covers OpenRouter quantization/routing, OpenAI service_tier,
Together safety_model, etc. without code changes.
Hardening from a 2-round multi-agent review:
- Zod deny-list rejects body-overwrite (model/messages/max_tokens/...) and
SDK-reserved keys (user/reasoningEffort/...) at parse time; runtime strip
is the second line of defense for hand-edits that bypass the dashboard.
- saveProvider/saveProviderSecret/deleteProvider gain a server-side
developerSettings check via requireDeveloperSettingsAccess (the route
gate alone was bypassable for any non-disabled member via Convex API).
- Image-gen raw fetch uses protected-keys-last spread + nested usage merge
to prevent provider-config injection of model/messages/modalities.
- Workflow engine threads modelData through executeAgentWithTools and
createAgentConfig; chat / openai_compat / translate / improve_message /
summarize / vision / title-gen all wire buildCallProviderOptions.
- Deletes 5 doubly-broken providerOptions: { openai: { maxOutputTokens } }
blocks (wrong key + wrong field name); moves caps to callSettings/top-
level maxOutputTokens which the SDK actually wires to max_tokens.
- TALE_DEBUG_LLM_WIRE=1 wraps the openai-compatible fetch and logs the
outgoing URL + redacted body for verifying merged fields reach the wire.
New OpenRouter model entries: GLM 5.1 / 5-turbo / 5v-turbo, Kimi K2.6,
Qwen 3.6 max-preview / plus / flash / 35b-a3b, Gemma 4 31b-it / 26b-a4b-it.
GLM 5.1 carries an example providerOptions block to document the feature.
Dashboard: read-only Card + Edit→Sheet for provider-level providerOptions,
inline JsonInput in the model add/edit dialog for per-model overrides, and
a "Custom options" badge on table rows that have providerOptions set so
operators can spot which models in a long list have been tuned.
i18n parity en/de/fr; docs section in providers.md with the gateway-vs-
direct framing and four-vendor examples (OpenRouter / Vercel AI Gateway /
OpenAI / Together AI). 593+ unit tests pass.
Follows the 60-agent multi-round review of the providerOptions passthrough
feature. Bundles ten branch-owned bugs and adjacent pre-existing tech debt
that surfaced clustered around the same files.
Schema & deny-list:
- Extend BODY_OVERWRITE_KEYS with n / logit_bias / logprobs / top_logprobs /
stream_options / store / metadata. Closes config-side cost-amp (n=5 → 5x
bill, only choices[0] returned), telemetry-corruption (stream_options),
payload-bloat (logprobs), and PII-egress (store, metadata) vectors. The
deny-list now also rejects array-typed top-level values, which would
otherwise spread numeric-key fields into the body.
- Export isPlainObject from convex/lib/provider_options for reuse.
Image-gen:
- Fix typeof-null trap on providerOptions.usage in
run_image_generation.ts:399 — non-empty array would have produced
{ '0':1, '1':2, ..., include:true } as the usage body field. Use
isPlainObject.
- Round image-gen cost via new roundCents helper in cost_estimation.ts so
providerCostUsd * 100 doesn't write 1.2000000000000002-style floats to
the usage ledger; matches estimateCostCents convention.
Auth & SSRF:
- fetchProviderModels and testProviderConnection now require
developerSettings (was: any-authenticated and orgMembership respectively),
matching saveProvider/saveProviderSecret/deleteProvider's threat model.
- New checkProviderHostPolicy helper hard-blocks IMDS hosts unconditionally
and rejects RFC1918 / loopback / link-local unless
TALE_ALLOW_PRIVATE_PROVIDER_HOSTS=1 is set (preserves the documented
self-hosted Ollama / vLLM / LocalAI use case).
- fetchProviderModels routes through safeFetch and no longer echoes the
upstream body in error responses (prevents partial-read SSRF via 4xx
body leaks). Same policy check covers the four probe helpers.
Provider mutations:
- Reverse deleteProvider unlink order (secrets first, public second) so a
failure in step 2 leaves a recoverable entry in the UI rather than an
orphaned ciphertext invisible to discovery.
- Audit-log every successful provider deletion under category 'security'.
- saveProvider wraps Zod failures into ConvexError({ code:
'INVALID_PROVIDER_CONFIG', issues:[{path,message}] }) so the dashboard
toast shows per-field errors instead of a stringified ZodError array.
Failover:
- Fix self-comparison `params.fallbackModelId !== params.fallbackModelId`
(always false → Attempt 3 was unreachable). Now compares
fallbackProviderName against the primary providerName, matching the
comment's intent.
LLM plumbing — migrate to per-call providerOptions:
- @convex-dev/agent's Agent({providerOptions}) is @deprecated; per-call
form (agent.streamText({providerOptions}) etc.) is the supported path
and is verified to forward through the SDK.
- Drop providerOptions from createAgentConfig; add modelMaxOutputTokens
with priority: caller maxTokens > model config > 8192 default. Fixes
the dead modelDefinition.maxOutputTokens schema field.
- Migrate seven Agent construction sites to per-call: vision_agent (+
analyze_image, analyze_image_by_url), translate_fields, improve_message,
generate_title (subsumes the double-call bug), generate_thread_title,
summarize_context, agent_chat → generate_response (4 streamText/
generateText callsites including continue/recovery retry loops).
- Wire providerOptions in workflows/triggers/actions.ts:39 (was the only
missed wiring on the prior commit) and confirm workflow_engine paths.
UI:
- $providerName.tsx model-edit dialog now derives the tag checkbox list
from modelTagLiterals, surfacing transcription as toggleable.
- Custom-options badge tooltip shows key names only (no full JSON).
- Three catch sites + provider-add-panel dispatch
FORBIDDEN_DEVELOPER_SETTINGS to a permissions-specific toast with new
i18n key (en/de/fr).
- ProviderOptionsEditor reads initialJson via ref so a parent refresh
during an edit doesn't clobber unsaved changes.
- Server Zod issues render per-line in the editor's save-error toast.
Debug & docs:
- TALE_DEBUG_LLM_WIRE docstring softened to match actual coverage (chat /
embedding / image only; transcription, probes, direct OpenRouter image
fetch are NOT covered) and redaction scope (only messages / input).
- stripDenyListed warn → error with code-regression wording.
- providers.md: replace stale Meta-Llama-Guard-3-8B with namespaced
meta-llama/Llama-Guard-4-12B, drop the inaccurate Llama-3.3-70B
Reference variant claim, fix the OpenRouter routing-docs link, document
Vercel header limitation, clarify TALE_DEBUG_LLM_WIRE deployment scope,
expand deny-list table.
Tests: 75/75 schema + provider_options + create_agent_config tests pass.
Full suite: 5691/5692 (one pre-existing flaky canvas-pane test, unrelated).
Follows a 65-agent two-round review of the providerOptions feature
(45 file/concern reviewers + 20 verifiers). Closes 8 HIGH and 8 MEDIUM
issues, all spot-checked against actual code.
Security & auth:
- saveProvider gates `config.baseUrl` AND each `models[].baseUrl`
through checkProviderHostPolicy. Closes the persisted-IMDS attack
vector — a developerSettings user could previously save a baseUrl
pointing at IMDS / internal services and exfil cloud creds on the
next chat/embedding/image/transcription call.
- Three probe helpers (runProbe / runTranscriptionProbe /
fetchProviderModelIds) now route through safeFetch, picking up
redirect: 'manual' so a 302 to IMDS can't carry the bearer token.
safeFetch body type extended to FormData for transcription probes.
- BLOCKED_METADATA_HOSTS extended with Alibaba 100.100.100.200 and
Oracle 192.0.0.192 (public IPs that slip past isPrivateIp),
Tencent metadata.tencentyun.com, and bare `metadata`. Trailing-dot
normalization closes the `metadata.google.internal.` bypass.
isPrivateIp now decodes IPv4-mapped IPv6 (::ffff:a.b.c.d dotted
and ::ffff:hhhh:hhhh hex forms).
- maskApiKey drops the trailing 3 ciphertext bytes (was leaked to
any non-disabled member). readProvider and hasProviderSecret
upgraded from requireOrgMembership to requireDeveloperSettingsAccess
to match the dashboard route's gating.
- Schema deny-list rejects __proto__ / constructor / prototype at any
depth as defense-in-depth against per-instance prototype pollution.
- checkProviderHostPolicy and validateUrl JSDocs document the
hostname-only check and the lack of DNS-pin against rebinding
(a known follow-up; undici Dispatcher would close it).
Correctness:
- BODY_OVERWRITE_KEYS extended with max_completion_tokens (OpenAI
reasoning-model token cap that bypassed max_tokens), reasoning_effort
and verbosity (snake_case forms the SDK silently overwrites with
undefined), and image-gen prompt and size (which the SDK image path
writes BEFORE the providerOptions spread, so an unguarded passthrough
could swap the user's prompt).
- docs Vercel example switched from metadata: { tale_agent: ... }
(which the deny-list now correctly rejects) to order: ['anthropic',
'openai'] — a routing field that actually parses.
- failover MAX_FAILOVER_ATTEMPTS bumped from 3 to 4. The prior commit's
Attempt-3 fix exposed Attempt 4 to slice() truncation: on main,
Attempt 4 was reachable because the self-comparison bug made
Attempt 3 unreachable (chain length stayed at 3); fixing Attempt 3
made the chain 4 long, but slice(0, 3) silently dropped the broadest
"any-provider tag search" safety net. New failover.test.ts pins all
four attempt branches.
- testProviderConnection probe body now merges provider+model
providerOptions through stripDenyListed, so a typo (e.g.
`provider.quanitzations`) surfaces as the same upstream 4xx the
user would hit on first real call, instead of a false-green
checkmark.
- Workflow LLM nodes (executeJsonOutputWithoutTools, executeTextOutput)
now thread modelData.maxOutputTokens through to createAgentConfig,
so the per-model cap is no longer silently ignored for workflow
steps (chat path was already wired in the prior commit).
- saveProvider accepts optional expectedHash for optimistic concurrency;
ProviderConfigProvider snapshots and round-trips the hash so two
operators editing the same provider get a PROVIDER_VERSION_CONFLICT
instead of a silent overwrite. Ledger idempotency-key part deferred
to a follow-up — it needs a schema column add.
UX & a11y:
- Toaster Description has whitespace-pre-line, so multi-line Zod-issue
toasts no longer collapse to one line.
- ProviderOptionsEditor's inner JsonInput replaced with a plain
monospace Textarea — eliminates the two-Save dance (inner Save vs
outer Save) and dodges JsonInput's keyboard-inaccessible visual
mode. Inline JSON validation; ConfirmDialog guards X / Cancel / Esc /
overlay close when the draft is dirty (prevents silent loss of
unsaved edits). Inputs disable while saving in flight. The
hardcoded "Example:" prose is now i18n'd; 4 new discard-confirm keys
ship in en/de/fr.
- Model-list TableRow rows are now keyboard-reachable (tabIndex=0,
role=button + onKeyDown for Enter/Space). Native title= Badge
tooltip replaced with the existing Tooltip primitive (keyboard-
accessible, themed). 8 bare <button> Edit pencils picked up
focus-visible: rings.
DRY:
- readConvexErrorData and dispatchForbiddenDeveloperSettings extracted
to app/features/settings/providers/utils/error-dispatch.ts; duplicate
inline copies in $providerName.tsx and provider-add-panel.tsx
removed.
Docs & i18n:
- providers.md fully translated to docs/de/ and docs/fr/. The feature
was English-only docs in the prior commit. docs/de-CH/ unchanged per
the overrides-only convention.
- Llama-Guard model ID reconciled across en/de/fr.json: was
Meta-Llama-Guard-3-8B in i18n, meta-llama/Llama-Guard-4-12B in the
English doc. Unified on the namespaced v4 form.
Deferred to follow-up (per plan):
- incrementUsageLedger idempotency-key — needs a schema column add and
call-site threading from generate_response.
- DNS rebinding mitigation via undici Dispatcher — JSDoc'd as a known
limitation here.
Tests: 3183/3183 pass (host_policy.test.ts 38 cases + failover.test.ts
8 cases new; full suite green). tsc --noEmit clean. oxlint --type-aware
clean.
adb7f8b to
33d88d3
Compare
…y, multi-org Round-1+2 agent review of this branch surfaced 9 HIGH and 9 MEDIUM real issues; this commit closes them. Backend: - failover: gate every modelId attempt on the circuit breaker (not just the primary), record resolution-side failures so dead fallbacks trip cooldown, throw structured NO_FAILOVER_ATTEMPTS when nothing is attemptable - safe_fetch: strip IPv6 brackets in isPrivateIp so [fc00::1], [fd00:ec2::254], [::ffff:7f00:1] no longer slip past the ULA / IPv4- mapped checks - saveProvider: expectedHash now also conflicts when the file was deleted since the snapshot (was silently resurrecting providers) - saveProviderSecret: tighten v.any() to v.record(v.string(), v.string()) and serialize per-(orgSlug, providerName) read-modify-write via an in-process advisory lock so concurrent secret saves don't clobber Frontend: - ProviderConfigContext: thread real orgSlug (was hardcoded 'default') through provider-add-panel, provider-default-models-panel, the context, and saveConfig; refresh hashRef on parent re-sync to stop spurious VERSION_CONFLICT toasts after sibling saveSecret invalidation - provider-edit-panel: route through ProviderConfigContext.saveConfig so expectedHash holds; the panel previously spread data.config and silently reverted concurrent providerOptions / models / defaults edits - provider-options-editor: snapshot initialJsonRef only on open->true so isDirty / discard-confirm work against the value the user actually saw, not the live parent prop - $providerName.handleDeleteModel: strip matching defaults entries so schema superRefine doesn't reject the cleanup save - provider-default-models-panel: add image-generation row (already in schema, missing from UI) - provider-add-panel: flag both rows of a duplicate model id, not just the second - error-dispatch: new dispatchVersionConflict + en/de/fr keys; chained through every catch site that previously fell into providers.saveFailed - editor a11y: aria-label on Textarea, role="region"+aria-label on the read-only <pre>; objectRequiredError now i18n-keyed (was hardcoded EN) - create_agent_config: maxTokens / modelMaxOutputTokens === 0 now omits max_tokens entirely (sending 0 is "generate zero tokens" upstream, not "unlimited" as JSDoc claimed) Latent / data: - analyzeImageCached: thread orgSlug into the cache key + uncached resolver so multi-tenant providerOptions are honored once a caller ships processAttachments - examples/providers/openrouter.json: glm-5-turbo / glm-5v-turbo priced below glm-5.1 (Turbo SKUs should be cheaper than flagship) - docs/en/.../providers.md: add 5 missing keys (max_completion_tokens, prompt, size, reasoning_effort, verbosity) to the body-overwrite table for parity with DE / FR and the schema - docs/de/.../providers.md: terminology fixes (Provider->Anbieter, Sie->informal) caught by the @tale/docs terminology test
Adds gpt-oss-120b, Kimi K2, DeepSeek V4 Pro, Qwen3-Coder 480B, Qwen3-235B-A22B, and wires GLM 5.1 / 5-Turbo / 5V-Turbo into the chat agent's supportedModels list.
Summary
providerOptionsfield at provider and model level (dashboard +providers/*.json) that forwards verbatim into LLM request bodies — covers OpenRouter quantization/routing, OpenAIservice_tier, Togethersafety_model, etc. without code changes. Zod deny-list + runtime strip block body-overwrite (model/messages/max_tokens/...) and SDK-reserved keys (user/reasoningEffort/...) at both layers.providerOptionsmigration across seven Agent construction sites (Agent-level form is@deprecated), SSRF host policy with IMDS hard-block + RFC1918 opt-in viaTALE_ALLOW_PRIVATE_PROVIDER_HOSTS,developerSettingsgate on all provider mutations + probes, secrets-first delete order, audit logging, Zod errors surfaced per-field in dashboard toasts, failover self-comparison fix (Attempt 3 was unreachable), image-gentypeof null === 'object'trap, cents-rounding for image-gen cost.providerOptionsblock.Pre-PR checklist
bun run check— 5691/5692 (one pre-existing flaky canvas-pane test, unrelated).services/platform/messages/{en,de,fr}.json(newerrors.forbiddenDeveloperSettingskey + provider-options strings)./docs/{en,de,fr}/for every user-visible change — EN only.docs/de/self-hosted/configuration/providers.mdanddocs/fr/.../providers.mdstill need the deny-list table,TALE_DEBUG_LLM_WIREscope clarification, and providerOptions framing translated. Flagging for follow-up.bun run --filter @tale/docs lintandbun run --filter @tale/docs test.Test plan
providerOptionsblock in the dashboard, save, and verify it persists across reload.providerOptionsoverride; confirm the "Custom options" badge appears on that row only.providerOptionscontainingmodelormessages; verify the dashboard shows a per-field Zod error toast.TALE_DEBUG_LLM_WIRE=1and confirm merged options reach the wire body.fetchProviderModels/testProviderConnectionvia Convex API directly — confirmFORBIDDEN_DEVELOPER_SETTINGStoast.TALE_ALLOW_PRIVATE_PROVIDER_HOSTS=1; confirm probe is blocked. Set the env var and confirm it succeeds.fallbackProviderName(not model id); confirm Attempt 3 now runs.Summary by CodeRabbit
Release Notes
New Features
Improvements
Documentation