Skip to content

feat(platform): providerOptions passthrough + provider hardening#1694

Merged
larryro merged 5 commits into
mainfrom
feat/provider-options-passthrough
May 10, 2026
Merged

feat(platform): providerOptions passthrough + provider hardening#1694
larryro merged 5 commits into
mainfrom
feat/provider-options-passthrough

Conversation

@larryro

@larryro larryro commented May 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds extensible providerOptions field at provider and model level (dashboard + providers/*.json) that forwards verbatim into LLM request bodies — covers OpenRouter quantization/routing, OpenAI service_tier, Together safety_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.
  • Hardening from a multi-round agent review: per-call providerOptions migration across seven Agent construction sites (Agent-level form is @deprecated), SSRF host policy with IMDS hard-block + RFC1918 opt-in via TALE_ALLOW_PRIVATE_PROVIDER_HOSTS, developerSettings gate 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-gen typeof null === 'object' trap, cents-rounding for image-gen cost.
  • Catalog refresh: 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 on OpenRouter. GLM 5.1 carries an example providerOptions block.
  • Dashboard: read-only Card + Edit→Sheet for provider-level options, inline JsonInput in model add/edit dialog, "Custom options" badge on rows that have overrides set.

Pre-PR checklist

  • Ran bun run check — 5691/5692 (one pre-existing flaky canvas-pane test, unrelated).
  • Updated services/platform/messages/{en,de,fr}.json (new errors.forbiddenDeveloperSettings key + provider-options strings).
  • Updated /docs/{en,de,fr}/ for every user-visible change — EN only. docs/de/self-hosted/configuration/providers.md and docs/fr/.../providers.md still need the deny-list table, TALE_DEBUG_LLM_WIRE scope clarification, and providerOptions framing translated. Flagging for follow-up.
  • Ran bun run --filter @tale/docs lint and bun run --filter @tale/docs test.
  • N/A — no README-level changes.

Test plan

  • Set a provider-level providerOptions block in the dashboard, save, and verify it persists across reload.
  • Set a per-model providerOptions override; confirm the "Custom options" badge appears on that row only.
  • Try saving a providerOptions containing model or messages; verify the dashboard shows a per-field Zod error toast.
  • Run a chat completion with TALE_DEBUG_LLM_WIRE=1 and confirm merged options reach the wire body.
  • Trigger image generation and confirm cost is rounded to integer cents in the usage ledger.
  • As a non-developerSettings member, attempt fetchProviderModels / testProviderConnection via Convex API directly — confirm FORBIDDEN_DEVELOPER_SETTINGS toast.
  • Configure a provider with an RFC1918 base URL without TALE_ALLOW_PRIVATE_PROVIDER_HOSTS=1; confirm probe is blocked. Set the env var and confirm it succeeds.
  • Force a primary provider failure where a fallback differs only in fallbackProviderName (not model id); confirm Attempt 3 now runs.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for configuring advanced provider options (JSON passthrough) at provider and model levels for enhanced LLM control
    • Expanded OpenRouter model catalog with new models from Z.ai, Moonshot, Qwen, and Google Gemma variants
    • Added new provider settings UI editor for managing provider options with schema validation and helpful error messages
  • Improvements

    • Enhanced provider connection security with host-policy enforcement
    • Improved error handling and validation for provider configurations
    • Added developer settings access control for sensitive provider operations
  • Documentation

    • Updated provider configuration guide with advanced options, gateway vs. direct vendor guidance, and debugging instructions

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This 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 callSettings, and the vision agent refactored to require caller-provided providerOptions in generateText.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • tale-project/tale#1693: Updates provider settings UI components and routes that are extended by this PR to support provider options editing.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the two main objectives: adding extensible providerOptions passthrough support and provider security hardening.
Description check ✅ Passed The description covers summary, pre-merge checklist with most items marked complete, and a detailed test plan. However, docs translation work for de/fr locales is explicitly flagged as pending.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/provider-options-passthrough
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/provider-options-passthrough

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.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Always 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 has isErrnoCode(), so the as NodeJS.ErrnoException assertions 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, or unknown in TypeScript — use type guards, generics, discriminated unions, or never instead."

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between e2c9140 and 0d2b0b9.

⛔ Files ignored due to path filters (1)
  • services/platform/convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (38)
  • docs/en/self-hosted/configuration/providers.md
  • examples/providers/openrouter.json
  • services/platform/app/features/settings/providers/components/provider-add-panel.tsx
  • services/platform/app/features/settings/providers/components/provider-options-editor.tsx
  • services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
  • services/platform/convex/agent_tools/files/helpers/analyze_image.ts
  • services/platform/convex/agent_tools/files/helpers/analyze_image_by_url.ts
  • services/platform/convex/agent_tools/files/helpers/vision_agent.ts
  • services/platform/convex/agents/image_generation/run_image_generation.ts
  • services/platform/convex/agents/translate_fields.ts
  • services/platform/convex/conversations/actions.ts
  • services/platform/convex/conversations/improve_message.ts
  • services/platform/convex/governance/cost_estimation.ts
  • services/platform/convex/lib/agent_chat/internal_actions.ts
  • services/platform/convex/lib/agent_response/generate_response.ts
  • services/platform/convex/lib/agent_response/types.ts
  • services/platform/convex/lib/create_agent_config.test.ts
  • services/platform/convex/lib/create_agent_config.ts
  • services/platform/convex/lib/provider_options.test.ts
  • services/platform/convex/lib/provider_options.ts
  • services/platform/convex/lib/summarization/auto_summarize.ts
  • services/platform/convex/lib/summarization/internal_actions.ts
  • services/platform/convex/lib/summarize_context.ts
  • services/platform/convex/openai_compat/internal_actions.ts
  • services/platform/convex/prompts/generate_title.ts
  • services/platform/convex/providers/auth.ts
  • services/platform/convex/providers/failover.ts
  • services/platform/convex/providers/file_actions.ts
  • services/platform/convex/providers/resolve_model.ts
  • services/platform/convex/threads/generate_thread_title.ts
  • services/platform/convex/workflow_engine/helpers/nodes/llm/execute_agent_with_tools.ts
  • services/platform/convex/workflow_engine/helpers/nodes/llm/execute_llm_node.ts
  • services/platform/convex/workflows/triggers/actions.ts
  • services/platform/lib/shared/schemas/providers.test.ts
  • services/platform/lib/shared/schemas/providers.ts
  • services/platform/messages/de.json
  • services/platform/messages/en.json
  • services/platform/messages/fr.json

Comment on lines +131 to +139
```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" }
}
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Comment on lines +117 to +145
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;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +286 to +288
<Text className="text-muted-foreground text-[12px]">
{`Example: ${PROVIDER_OPTIONS_PLACEHOLDER.replace(/\s+/g, ' ')}`}
</Text>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Comment on lines +792 to +817
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;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +94 to +123
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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.ts

Repository: 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).

Comment on lines +231 to +234
* 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment thread services/platform/messages/de.json Outdated
Comment on lines +1037 to +1047
"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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
"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).

Comment on lines +1036 to +1046
"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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Comment thread services/platform/messages/en.json Outdated
"save": "Save provider options",
"saveSuccess": "Provider options saved",
"saveError": "Failed to save provider options",
"invalidJson": "Provider options is not valid JSON"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Suggested change
"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.

larryro added 3 commits May 10, 2026 11:41
…/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.
@larryro larryro force-pushed the feat/provider-options-passthrough branch from adb7f8b to 33d88d3 Compare May 10, 2026 03:48
larryro added 2 commits May 10, 2026 13:33
…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.
@larryro larryro merged commit 4a38699 into main May 10, 2026
19 checks passed
@larryro larryro deleted the feat/provider-options-passthrough branch May 10, 2026 07:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant