Skip to content

(MOT-4348) feat(provider-deepseek): add the DeepSeek provider worker - #691

Merged
andersonleal merged 1 commit into
mainfrom
feat/deep-seek-provider
Aug 4, 2026
Merged

(MOT-4348) feat(provider-deepseek): add the DeepSeek provider worker#691
andersonleal merged 1 commit into
mainfrom
feat/deep-seek-provider

Conversation

@andersonleal

@andersonleal andersonleal commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Adds provider-deepseek, a DeepSeek Chat Completions provider behind llm-router. Paste a DeepSeek API key into the provider's slice of the llm-router config and deepseek-v4-pro / deepseek-v4-flash appear in the model picker with cost tracking, and the model's chain of thought streams into the console as it works.

Modeled on provider-zai; every DeepSeek-specific behavior is written against api-docs.deepseek.com (snapshot 2026-08). Registers provider::deepseek::stream, ::abort, ::refresh_models, and ::on_router_ready.

Where it diverges from the sibling providers, and why

  • Live discovery. DeepSeek documents GET /models, so ids come from upstream and the local table only supplies what the listing lacks (limits, pricing). An unknown id lands on conservative defaults instead of vanishing, so a model DeepSeek ships tomorrow is routable today.
  • usage.input is the cache-miss slice. The spec calls these fields disjoint prompt-cache splits and fill_cost_usd adds them, so feeding the prompt_tokens total would bill the cached prefix twice. With DeepSeek's ~120x cache discount that roughly doubles reported cost on an agent loop. Note the OpenAI-family siblings do feed the total — a pre-existing over-report left untouched here rather than changing five workers in this PR.
  • Reasoning on by default. With no thinking level requested both thinking and reasoning_effort are omitted, so each model runs its documented default (V4: thinking at high effort) and an unconfigured chat renders its reasoning. disabled is never sent — the router has no off level to express, and a synthetic off-by-default blanks the console's thinking pane.
  • Reasoning replay, scoped. reasoning_content is replayed on tool-calling messages only: the API 400s a tool round whose intermediate reasoning was dropped, and documents it as ignored everywhere else, where resending would re-bill the whole chain as input every turn.
  • Prefix-stable serialization. DeepSeek's automatic cache keys on shared request prefixes, so every wire rule depends only on a message's own content, keeping a growing transcript append-only (pinned by a test). The two exceptions that mutate history for correctness — a late tool result replacing its orphan placeholder, and latest-wins dedup — cost one cache bust each and are documented as such.
  • Ordered blocks. The assembled message keeps arrival order with thinking/text/calls interleaved, rather than one merged block per kind — the shape provider-anthropic already produces, and the one the *_start/*_end frames were already describing on the wire.
  • Errors. 402 (out of balance) is permanent, not a retryable rate limit. finish_reason: insufficient_system_resource terminates as transient so the router retries instead of returning a silently truncated answer.
  • Text only. No multimodal content-part array is documented, so image blocks degrade to a text marker with a report-and-continue warning instead of failing the whole turn.

Testing

cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings (zero warnings), and 85 tests green:

  • 74 unit tests over the pure modules plus TCP-stub upstreams
  • 4 golden wire-schema snapshots (typed request/response, no AnyValue)
  • 5 engine-backed integration tests booting a real engine, the real router, this provider, and a stub upstream serving both /models and /chat/completions — no external API calls anywhere

Also verified validate_worker.py passes and the manifest emits cleanly.

Notes for review

  • Version stays 0.1.0; per repo convention the CI bot bumps after merge.
  • Wired into create-tag.yml and release.yml per docs/sops/new-worker.md §6. Not added to alpha-release.yml — the SOP's four-row table doesn't cover it and the omission blocks nothing; happy to add if you'd rather keep that list exhaustive.
  • First release should probably go out on registry tag next per §9.

Fixes MOT-4348

Summary by CodeRabbit

  • New Features

    • Added DeepSeek as a supported provider worker.
    • Added streaming chat responses with tool calls, reasoning, usage tracking, prompt caching, structured output, and error handling.
    • Added live model discovery with model metadata, capabilities, limits, and pricing.
    • Added provider registration, credential handling, cancellation, and configurable upstream endpoints.
  • Documentation

    • Added setup, configuration, usage, CLI, and integration documentation for DeepSeek.
    • Updated release workflows and module listings to include the new provider.

Implements the provider protocol behind llm-router: provider::deepseek::stream,
::abort, ::refresh_models, and ::on_router_ready. Modeled on provider-zai, with
the DeepSeek-specific behavior written against api-docs.deepseek.com.

- Catalog: live discovery through GET /models, enriched from a local metadata
  table since the listing returns bare ids. Unknown ids land on conservative
  defaults rather than disappearing.
- Reasoning: thinking + top-level reasoning_effort when a level is requested,
  both omitted otherwise so each model runs its documented default and an
  unconfigured chat streams its chain of thought into the console.
- Reasoning replay: sent back as reasoning_content on tool-calling messages
  only, which the API requires there and ignores everywhere else.
- Prompt caching: the wire layer serializes a growing session append-only so
  the upstream prefix cache keeps hitting; the hit slice maps to
  usage.cache_read and the miss slice to usage.input, so the router's additive
  cost fill bills each prompt token exactly once.
- Blocks keep the order the model produced them, with thinking, text, and tool
  calls interleaved rather than merged per kind.
- Errors: 402 is permanent, not a retryable rate limit; a truncated generation
  (insufficient_system_resource) is transient so the router retries.
- Text only: images degrade to a marker with a warning instead of failing the
  turn on an API with no multimodal content-part array.

Registers the worker in the README module table and wires it into create-tag
and release.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a complete Rust provider-deepseek worker. It includes credential handling, model discovery, request and message serialization, reasoning, SSE streaming, registration, schemas, integration tests, documentation, and release workflow integration.

Changes

DeepSeek provider worker

Layer / File(s) Summary
Worker foundation and router contracts
provider-deepseek/Cargo.toml, provider-deepseek/src/*, provider-deepseek/iii*.yaml
Adds the Cargo package, worker manifests, CLI, provider registry manifest, operation catalog, router wrappers, and token persistence.
Configuration, catalog, errors, and discovery
provider-deepseek/src/config.rs, curated.rs, errors.rs, discovery.rs
Resolves credentials and endpoints, enriches known and unknown models, classifies upstream errors, and reconciles live model catalogs.
Registration and request wire format
provider-deepseek/src/register.rs, reasoning.rs, request.rs, wire/*
Registers the provider, configures retries and router-ready handling, maps reasoning settings, builds chat requests, and serializes messages and tools.
Streaming transport and event conversion
provider-deepseek/src/upstream.rs, sse.rs, stream_fn.rs
Posts streaming requests, parses SSE data, preserves ordered thinking, text, and tool events, handles usage and errors, and supports aborts.
Schemas and integration validation
provider-deepseek/tests/*
Adds golden schemas, schema snapshot checks, local upstream stubs, engine-backed streaming tests, authentication tests, discovery tests, and restart tests.
Documentation and release wiring
README.md, provider-deepseek/README.md, provider-deepseek/prompts/identity.txt, .github/workflows/*
Documents the worker and identity prompt, lists it in project modules, and enables manual tagging and release triggers.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Router
  participant StreamWorker
  participant UpstreamTransport
  participant DeepSeekAPI
  Router->>StreamWorker: Submit provider stream request
  StreamWorker->>StreamWorker: Resolve credentials and build request
  StreamWorker->>UpstreamTransport: Start upstream stream
  UpstreamTransport->>DeepSeekAPI: POST chat completions
  DeepSeekAPI-->>UpstreamTransport: Return SSE chunks
  UpstreamTransport-->>StreamWorker: Emit assistant events
  StreamWorker-->>Router: Relay stream events and completion
Loading

Possibly related PRs

  • iii-hq/workers#382: Adds a structurally parallel Rust llm-router provider worker.
  • iii-hq/workers#525: Introduces shared provider_scaffold utilities used by this worker.
  • iii-hq/workers#443: Adds another OpenAI-compatible provider worker with parallel registration, discovery, streaming, and test modules.

Suggested reviewers: sergiofilhowz

Poem

A rabbit hops through streams of light,
DeepSeek answers bloom in flight.
Models refresh, tools align,
Reasoning follows every sign.
Tags now guide the worker’s way—
“Hop, ship, and stream today!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding the DeepSeek provider worker.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ 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/deep-seek-provider

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 54 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (5)
provider-deepseek/tests/support/mod.rs (1)

55-90: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Report trailing-whitespace-only mismatches explicitly.

check_golden compares whole strings, but diff_hint splits with lines(). If the only difference is a missing or extra trailing newline, first_diff lands at the common length and both printed blocks are identical. The reader then sees a mismatch with no visible divergence. Add an explicit note for that case.

♻️ Proposed fix
     let mut out = format!(
         "golden mismatch: tests/golden/{rel}\n\
          first divergence at line {} (expected {} lines, actual {} lines)\n",
         first_diff + 1,
         exp_lines.len(),
         act_lines.len()
     );
+    if exp_lines == act_lines {
+        out.push_str(
+            "line contents are identical: the difference is trailing whitespace \
+             or a trailing newline.\n",
+        );
+    }
🤖 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 `@provider-deepseek/tests/support/mod.rs` around lines 55 - 90, Update
diff_hint to explicitly detect when expected and actual differ only in trailing
newline or other end-of-string whitespace despite having identical lines, and
append a clear note identifying the trailing-whitespace-only mismatch. Preserve
the existing line-diff output for all substantive differences.
provider-deepseek/tests/schemas.rs (1)

95-107: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Do not pass no_orphan_schema_goldens when the golden directory is unreadable.

Err(_) => return turns a missing or unreadable tests/golden/schemas into a silent pass. A rename or an accidental directory removal then goes undetected, which is the case this test exists to catch. Also restrict the scan to .json files, so a nested directory or a stray file does not fail with an orphan-golden message.

♻️ Proposed fix
-    let entries = match std::fs::read_dir(&dir) {
-        Ok(e) => e,
-        Err(_) => return,
-    };
+    let entries = std::fs::read_dir(&dir)
+        .unwrap_or_else(|e| panic!("read {}: {e}", dir.display()));
     for entry in entries.filter_map(Result::ok) {
         let name = entry.file_name().to_string_lossy().into_owned();
+        if !name.ends_with(".json") {
+            continue;
+        }
         assert!(
             expected.iter().any(|e| e == &name),
             "orphan golden tests/golden/schemas/{name}: no catalog entry \
              produces it. Delete it or fix the catalog."
         );
     }
🤖 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 `@provider-deepseek/tests/schemas.rs` around lines 95 - 107, Update
no_orphan_schema_goldens so read_dir errors fail the test instead of returning
silently, ensuring a missing or unreadable golden directory cannot pass. While
scanning entries, inspect only regular files with a .json extension before
comparing names against expected, leaving unrelated files or directories out of
orphan validation.
provider-deepseek/README.md (1)

32-42: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Bound the pricing claim to an effective date.

GET /models supplies model IDs, while src/curated.rs supplies the prices. Unless that table is refreshed or versioned with every DeepSeek rate change, a released binary can report stale costs after regular or peak/off-peak pricing changes. Replace “reported cost is exact” with an effective-date or estimate statement, or add a runtime/versioned pricing source.

Suggested wording
- The prices are DeepSeek's regular rates, which is what is billed today, so reported cost is exact.
+ The catalog prices are a checked-in snapshot. Reported cost is exact only for its documented effective date.
🤖 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 `@provider-deepseek/README.md` around lines 32 - 42, Update the README pricing
description near the Catalog section to avoid claiming reported costs are exact
indefinitely. Describe the curated rates as estimates or state their effective
date, and retain the existing explanation that catalog entries use conservative
defaults and cannot represent time-of-day pricing.
provider-deepseek/prompts/identity.txt (1)

289-304: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Scope the registration API rule to the selected language.

registerWorker() / registerFunction() applies only to Node/TypeScript. Python and Rust use snake_case APIs such as register_worker(), register_function(), and register_trigger(). Restrict this paragraph to Node/TypeScript, then have other language workers use their corresponding SDK reference.

🤖 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 `@provider-deepseek/prompts/identity.txt` around lines 289 - 304, Update the
worker registration guidance in the identity prompt to explicitly scope
registerWorker, registerFunction, registerTrigger, and trigger to
Node/TypeScript only. Direct Python and Rust workers to follow their selected
SDK references and use the corresponding snake_case APIs, including
register_worker, register_function, and register_trigger.
provider-deepseek/src/upstream.rs (1)

44-52: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Accept data: without the optional space, and join multi-line data.

The SSE specification makes the space after data: optional and concatenates multiple data: lines in one event with \n. data_line requires the space and keeps only the last line. DeepSeek itself is fine, but this provider deliberately supports OpenAI-compatible endpoints behind an api_url override, and a gateway that writes data:{…} yields zero events — the stream then ends with an empty Done, which looks like an empty completion rather than a failure.

♻️ Proposed refactor
-/// Last `data: ` payload in an SSE block, if any. Blocks that carry only
+/// The `data` payload of an SSE block, if any. Multiple `data` lines join
+/// with `\n` per the SSE specification, and the space after the colon is
+/// optional. Blocks that carry only
 /// comments — DeepSeek emits `: keep-alive` while an overloaded scheduler
 /// makes the request wait — have none and decode to zero events.
-fn data_line(block: &str) -> Option<&str> {
-    block
-        .lines()
-        .filter_map(|l| l.strip_prefix("data: "))
-        .next_back()
-}
+fn data_line(block: &str) -> Option<String> {
+    let mut parts = block
+        .lines()
+        .filter_map(|l| l.strip_prefix("data:"))
+        .map(|v| v.strip_prefix(' ').unwrap_or(v))
+        .peekable();
+    parts.peek().is_some().then(|| parts.collect::<Vec<_>>().join("\n"))
+}

The call site at Line 113 then compares data.as_str().

🤖 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 `@provider-deepseek/src/upstream.rs` around lines 44 - 52, Update data_line to
recognize both “data:” and “data: ” SSE fields, then collect every data field in
the block and join their values with newline separators instead of returning
only the last line. Preserve the existing None result for comment-only blocks,
and ensure the returned value remains compatible with the call site’s
data.as_str() comparison.
🤖 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 `@provider-deepseek/src/discovery.rs`:
- Around line 83-96: Normalize the credential in refresh_models using the shared
helper already used by config_from_resolve before calling credential_parts.
Treat a blank normalized result as missing, reconcile with an empty model list,
and return early; otherwise pass the normalized credential to fetch_live_models.
Add a refresh test covering a trailing-newline credential and verifying the
normalized bearer value.
- Around line 31-44: Update parse_live_models and the fetch_live_models success
path so a 2xx payload is considered valid only when it contains a data array;
return a non-success outcome for missing or malformed data instead of
reconciling an empty catalog. Preserve data: [] as the valid empty-success case,
and add a refresh test proving malformed 2xx responses leave the existing
provider catalog unchanged.
- Around line 20-26: Update models_url and its refresh_models caller so
unsupported api_url values are rejected or resolved using the configured API
origin rather than falling back to api.deepseek.com; preserve the existing
catalog when URL derivation is rejected, and update the fallback test covering
models_url to assert the new behavior.

In `@provider-deepseek/src/register.rs`:
- Around line 121-127: Update read_timeout so a parsed
PROVIDER_READ_TIMEOUT_SECS value of zero is rejected and falls back to the
existing 120-second default; preserve positive values as configured and keep the
current environment parsing flow.

In `@provider-deepseek/src/sse.rs`:
- Around line 292-306: Update the mid-stream error handling in the SSE chunk
processing path around the `if let Some(err) = chunk.get("error")` block to call
`close_open_block` before pushing the terminal `AssistantMessageEvent::Error`,
matching the `insufficient_system_resource` path. Preserve the existing error
classification and partial-content handling.

In `@provider-deepseek/src/upstream.rs`:
- Around line 77-90: Update the non-success response handling around classify
and synthetic_error_event to read the error body with a bounded size rather than
calling resp.text() unconditionally. Retain only the leading few kilobytes,
trimming to a valid UTF-8 boundary by walking backward with is_char_boundary,
and use the truncated text for classification and the emitted error message.
- Around line 133-161: After the stream ends in the while loop, before sending
AssistantMessageEvent::Done with build_final, process any remaining incomplete
SSE block left in buf. The drain_sse_blocks function only handles complete
\n\n-terminated blocks, so a connection close can leave a final data: {...} line
unprocessed. Flush this trailing block by decoding it through the same decode
flow used in drain_sse_blocks to update state with the final payload before
build_final constructs the Done message.

In `@provider-deepseek/tests/integration.rs`:
- Around line 91-126: Update spawn_engine to construct the Engine owner
immediately after spawning the child and before the readiness polling loop, so
Engine::drop handles failed readiness assertions and cleans up the process and
temporary directory. Preserve the existing URL and child ownership, cloning the
URL or reusing engine.url as needed before register_worker and the final return.
- Around line 320-326: Remove the process-global std::env::remove_var call from
provider_registers_with_persisted_token_and_credential_gated_catalog. Configure
the test’s engine or router setup through the existing explicit configuration
mechanism to pin DEEPSEEK_API_KEY as empty for this test only, preserving the
no-credential assertions without affecting parallel tests.

---

Nitpick comments:
In `@provider-deepseek/prompts/identity.txt`:
- Around line 289-304: Update the worker registration guidance in the identity
prompt to explicitly scope registerWorker, registerFunction, registerTrigger,
and trigger to Node/TypeScript only. Direct Python and Rust workers to follow
their selected SDK references and use the corresponding snake_case APIs,
including register_worker, register_function, and register_trigger.

In `@provider-deepseek/README.md`:
- Around line 32-42: Update the README pricing description near the Catalog
section to avoid claiming reported costs are exact indefinitely. Describe the
curated rates as estimates or state their effective date, and retain the
existing explanation that catalog entries use conservative defaults and cannot
represent time-of-day pricing.

In `@provider-deepseek/src/upstream.rs`:
- Around line 44-52: Update data_line to recognize both “data:” and “data: ” SSE
fields, then collect every data field in the block and join their values with
newline separators instead of returning only the last line. Preserve the
existing None result for comment-only blocks, and ensure the returned value
remains compatible with the call site’s data.as_str() comparison.

In `@provider-deepseek/tests/schemas.rs`:
- Around line 95-107: Update no_orphan_schema_goldens so read_dir errors fail
the test instead of returning silently, ensuring a missing or unreadable golden
directory cannot pass. While scanning entries, inspect only regular files with a
.json extension before comparing names against expected, leaving unrelated files
or directories out of orphan validation.

In `@provider-deepseek/tests/support/mod.rs`:
- Around line 55-90: Update diff_hint to explicitly detect when expected and
actual differ only in trailing newline or other end-of-string whitespace despite
having identical lines, and append a clear note identifying the
trailing-whitespace-only mismatch. Preserve the existing line-diff output for
all substantive differences.
🪄 Autofix

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: CHILL

Plan: Pro Plus

Run ID: 9d40156f-dfae-4eab-8022-d7c73ec88e93

📥 Commits

Reviewing files that changed from the base of the PR and between b3a44c4 and c33b01e.

⛔ Files ignored due to path filters (1)
  • provider-deepseek/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (38)
  • .github/workflows/create-tag.yml
  • .github/workflows/release.yml
  • README.md
  • provider-deepseek/.gitignore
  • provider-deepseek/Cargo.toml
  • provider-deepseek/README.md
  • provider-deepseek/build.rs
  • provider-deepseek/config.yaml
  • provider-deepseek/iii-permissions.yaml
  • provider-deepseek/iii.worker.yaml
  • provider-deepseek/prompts/identity.txt
  • provider-deepseek/src/config.rs
  • provider-deepseek/src/curated.rs
  • provider-deepseek/src/discovery.rs
  • provider-deepseek/src/errors.rs
  • provider-deepseek/src/lib.rs
  • provider-deepseek/src/main.rs
  • provider-deepseek/src/manifest.rs
  • provider-deepseek/src/reasoning.rs
  • provider-deepseek/src/register.rs
  • provider-deepseek/src/request.rs
  • provider-deepseek/src/router_client.rs
  • provider-deepseek/src/sse.rs
  • provider-deepseek/src/state.rs
  • provider-deepseek/src/stream_fn.rs
  • provider-deepseek/src/surface.rs
  • provider-deepseek/src/upstream.rs
  • provider-deepseek/src/wire/messages.rs
  • provider-deepseek/src/wire/mod.rs
  • provider-deepseek/src/wire/names.rs
  • provider-deepseek/src/wire/tools.rs
  • provider-deepseek/tests/golden/schemas/provider.deepseek.abort.json
  • provider-deepseek/tests/golden/schemas/provider.deepseek.on_router_ready.json
  • provider-deepseek/tests/golden/schemas/provider.deepseek.refresh_models.json
  • provider-deepseek/tests/golden/schemas/provider.deepseek.stream.json
  • provider-deepseek/tests/integration.rs
  • provider-deepseek/tests/schemas.rs
  • provider-deepseek/tests/support/mod.rs

Comment on lines +20 to +26
pub fn models_url(api_url: &str) -> String {
api_url
.trim_end_matches('/')
.strip_suffix("/chat/completions")
.map(|base| format!("{base}/models"))
.unwrap_or_else(|| "https://api.deepseek.com/models".to_string())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== discovery.rs outline =="
ast-grep outline provider-deepseek/src/discovery.rs || true

echo "== discovery.rs relevant lines =="
cat -n provider-deepseek/src/discovery.rs | sed -n '1,230p'

echo "== fetch/call sites for models_url and refresh_models =="
rg -n "models_url|fetch_live_models|refresh_models|parse_live_models|reconcile" provider-deepseek/src -S

Repository: iii-hq/workers

Length of output: 12736


🌐 Web query:

DeepSeek API /models endpoint credentials Bearer token origin api.deepseek.com

💡 Result:

To authenticate with the DeepSeek API, including the /models endpoint, you must use an API key obtained from the DeepSeek platform [1][2]. The authentication mechanism is a standard HTTP Bearer token [1][2]. You must include an Authorization header in every API request formatted as follows [3][2][4]: Authorization: Bearer YOUR_API_KEY Key details regarding this credential include: - Obtaining Keys: You can generate your API key by creating an account and navigating to the API Key Management page on the official DeepSeek platform (platform.deepseek.com) [5][4]. - Request Header: The Authorization header is mandatory for all endpoints at api.deepseek.com [2]. Omitting the header, using an empty value, or providing an invalid/revoked key will result in a 401 Unauthorized response [6][4]. - Compatibility: Because the DeepSeek API is designed to be compatible with the OpenAI API format, you can pass this token directly when using standard OpenAI SDKs by setting the base_url to https://api.deepseek.com and providing your key as the api_key [5][7][4]. - Security: DeepSeek API keys are sensitive strings (typically starting with sk-). They should be stored securely and never hardcoded in your source code [3][4]. If you encounter errors, a 401 status indicates an authentication issue (missing or invalid key), while a 402 status indicates that your account's balance has been exhausted [2][4].

Citations:


Sensitive Data Exposure (CWE-201)

Reachability: Internal

Reachability path
● Entry
  provider-deepseek/src/lib.rs
│
▼
● Hop
  provider-deepseek/src/manifest.rs:33
  json_roundtrip_has_required_fields
│
▼
● Sink
  provider-deepseek/src/discovery.rs

Keep override credentials on the override origin.

models_url falls back to https://api.deepseek.com/models for unsupported api_url values, and refresh_models sends the configured bearer credential to that fallback origin. This can expose an override key to DeepSeek instead of the configured upstream and reconcile the wrong catalog.

Reject unrecognized model-generation paths or derive /models from the parsed API origin. On rejection, preserve the existing catalog and update the fallback test at provider-deepseek/src/discovery.rs:146-150.

🤖 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 `@provider-deepseek/src/discovery.rs` around lines 20 - 26, Update models_url
and its refresh_models caller so unsupported api_url values are rejected or
resolved using the configured API origin rather than falling back to
api.deepseek.com; preserve the existing catalog when URL derivation is rejected,
and update the fallback test covering models_url to assert the new behavior.

Comment on lines +31 to +44
pub fn parse_live_models(json: &Value) -> Vec<Model> {
json.get("data")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|raw| {
raw.get("id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
})
.map(enrich)
.collect()
})
.unwrap_or_default()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not reconcile malformed 2xx payloads as an empty catalog.

A response without a data array becomes Vec::new(). fetch_live_models treats that as success, and reconcile replaces the existing provider slice with an empty slice. A 2xx error envelope or schema change can therefore remove every catalog row.

Validate the envelope before returning FetchOutcome::Ok. Keep data: [] as the only empty-success case. Add a refresh test that verifies malformed 2xx payloads preserve the prior catalog.

Proposed fix
 match resp.json::<Value>().await {
-    Ok(v) => FetchOutcome::Ok(parse_live_models(&v)),
+    Ok(v) if v.get("data").and_then(Value::as_array).is_some() => {
+        FetchOutcome::Ok(parse_live_models(&v))
+    }
+    Ok(_) => FetchOutcome::Transient(
+        "models response missing a data array".to_string(),
+    ),
     Err(e) => FetchOutcome::Transient(format!("models response not json: {e}")),
 }

Also applies to: 72-75

🤖 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 `@provider-deepseek/src/discovery.rs` around lines 31 - 44, Update
parse_live_models and the fetch_live_models success path so a 2xx payload is
considered valid only when it contains a data array; return a non-success
outcome for missing or malformed data instead of reconciling an empty catalog.
Preserve data: [] as the valid empty-success case, and add a refresh test
proving malformed 2xx responses leave the existing provider catalog unchanged.

Comment on lines +83 to +96
let Some(credential) = resolved.credential else {
// Key removed: prune the slice so the picker reflects removal
// instead of showing stale, unusable rows.
router_client::reconcile(iii, vec![], token.as_deref()).await?;
return Ok(0);
};

let api_url = resolved
.api_url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_API_URL);
match fetch_live_models(http, &models_url(api_url), credential_parts(&credential)).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize the discovery credential before constructing the header.

config_from_resolve trims credentials and rejects blank values. refresh_models sends credential_parts(&credential) without normalization. A pasted trailing newline therefore produces a different bearer value for discovery than for streaming.

Use one shared credential-normalization helper. Treat an empty normalized value as no credential before reconciliation. Add a refresh test with a trailing newline.

🤖 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 `@provider-deepseek/src/discovery.rs` around lines 83 - 96, Normalize the
credential in refresh_models using the shared helper already used by
config_from_resolve before calling credential_parts. Treat a blank normalized
result as missing, reconcile with an empty model list, and return early;
otherwise pass the normalized credential to fetch_live_models. Add a refresh
test covering a trailing-newline credential and verifying the normalized bearer
value.

Comment on lines +121 to +127
fn read_timeout() -> Duration {
std::env::var("PROVIDER_READ_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(120))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reject a zero value for PROVIDER_READ_TIMEOUT_SECS.

Duration::from_secs(0) is not "no timeout" for reqwest read_timeout; it bounds every read at zero and terminates each stream immediately. Operators commonly use 0 to mean "disabled". Filter it so the documented default applies.

🛡️ Proposed guard
 fn read_timeout() -> Duration {
     std::env::var("PROVIDER_READ_TIMEOUT_SECS")
         .ok()
-        .and_then(|s| s.parse().ok())
+        .and_then(|s| s.parse::<u64>().ok())
+        .filter(|secs| *secs > 0)
         .map(Duration::from_secs)
         .unwrap_or(Duration::from_secs(120))
 }
📝 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
fn read_timeout() -> Duration {
std::env::var("PROVIDER_READ_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(120))
}
fn read_timeout() -> Duration {
std::env::var("PROVIDER_READ_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|secs| *secs > 0)
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(120))
}
🤖 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 `@provider-deepseek/src/register.rs` around lines 121 - 127, Update
read_timeout so a parsed PROVIDER_READ_TIMEOUT_SECS value of zero is rejected
and falls back to the existing 120-second default; preserve positive values as
configured and keep the current environment parsing flow.

Comment on lines +292 to +306
// Mid-stream error envelope (some gateways send {"error": {...}} as a
// chunk): terminal error frame carrying the partial content.
if let Some(err) = chunk.get("error") {
let msg = err
.get("message")
.and_then(Value::as_str)
.unwrap_or("upstream error")
.to_string();
state.stop_reason = StopReason::Error;
state.error_message = Some(msg.clone());
let mut error = build_final(state, model);
error.error_kind = Some(classify(None, &chunk.to_string()));
events.push(AssistantMessageEvent::Error { error });
return events;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Close the open block before the mid-stream error terminal.

The insufficient_system_resource path calls close_open_block before it pushes Error (Line 424). This path does not. A consumer that received TextStart or ThinkingStart therefore never receives the matching TextEnd/ThinkingEnd when a gateway sends an {"error": …} chunk mid-generation. The two terminal paths should emit the same block-boundary sequence.

🐛 Proposed fix
         state.stop_reason = StopReason::Error;
         state.error_message = Some(msg.clone());
+        close_open_block(state, model, &mut events);
         let mut error = build_final(state, model);
         error.error_kind = Some(classify(None, &chunk.to_string()));
         events.push(AssistantMessageEvent::Error { error });
         return events;
📝 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
// Mid-stream error envelope (some gateways send {"error": {...}} as a
// chunk): terminal error frame carrying the partial content.
if let Some(err) = chunk.get("error") {
let msg = err
.get("message")
.and_then(Value::as_str)
.unwrap_or("upstream error")
.to_string();
state.stop_reason = StopReason::Error;
state.error_message = Some(msg.clone());
let mut error = build_final(state, model);
error.error_kind = Some(classify(None, &chunk.to_string()));
events.push(AssistantMessageEvent::Error { error });
return events;
}
// Mid-stream error envelope (some gateways send {"error": {...}} as a
// chunk): terminal error frame carrying the partial content.
if let Some(err) = chunk.get("error") {
let msg = err
.get("message")
.and_then(Value::as_str)
.unwrap_or("upstream error")
.to_string();
state.stop_reason = StopReason::Error;
state.error_message = Some(msg.clone());
close_open_block(state, model, &mut events);
let mut error = build_final(state, model);
error.error_kind = Some(classify(None, &chunk.to_string()));
events.push(AssistantMessageEvent::Error { error });
return events;
}
🤖 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 `@provider-deepseek/src/sse.rs` around lines 292 - 306, Update the mid-stream
error handling in the SSE chunk processing path around the `if let Some(err) =
chunk.get("error")` block to call `close_open_block` before pushing the terminal
`AssistantMessageEvent::Error`, matching the `insufficient_system_resource`
path. Preserve the existing error classification and partial-content handling.

Comment on lines +77 to +90
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
let kind = classify(Some(status.as_u16()), &text);
let msg = if text.is_empty() {
format!("deepseek http {status}")
} else {
text
};
let _ = tx
.send(synthetic_error_event(&msg, &args.model, kind))
.await;
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bound the error-body read.

resp.text() buffers the whole non-2xx body with no size limit. The success path streams in chunks, so it is bounded; this path is not. read_timeout bounds silence between reads, not total volume, so a broken or hostile endpoint that streams a large error body makes the worker allocate all of it. Truncate to a few kilobytes — the classifier and the operator only need the leading text.

🛡️ Proposed bound
     let status = resp.status();
     if !status.is_success() {
-        let text = resp.text().await.unwrap_or_default();
+        const MAX_ERROR_BODY: usize = 8 * 1024;
+        let mut text = resp.text().await.unwrap_or_default();
+        if text.len() > MAX_ERROR_BODY {
+            text.truncate(text.floor_char_boundary(MAX_ERROR_BODY));
+            text.push_str("… (truncated)");
+        }
         let kind = classify(Some(status.as_u16()), &text);

floor_char_boundary is unstable; on stable, walk back to the nearest boundary with is_char_boundary.

🤖 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 `@provider-deepseek/src/upstream.rs` around lines 77 - 90, Update the
non-success response handling around classify and synthetic_error_event to read
the error body with a bounded size rather than calling resp.text()
unconditionally. Retain only the leading few kilobytes, trimming to a valid
UTF-8 boundary by walking backward with is_char_boundary, and use the truncated
text for classification and the emitted error message.

Comment on lines +133 to +161
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
let _ = tx
.send(synthetic_error_event(
&format!("stream read failed: {e}"),
&args.model,
ErrorKind::Transient,
))
.await;
return;
}
};
append_utf8_chunk(&mut byte_buf, &mut buf, &chunk);
if drain_sse_blocks(&mut buf, &tx, &mut |block: &str| {
decode(block, &mut state, &args.model)
})
.await
{
return; // terminal forwarded, or receiver dropped → abort upstream
}
}
// Stream ended without [DONE] (connection close framing): still terminal.
let _ = tx
.send(AssistantMessageEvent::Done {
message: build_final(&state, &args.model),
})
.await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect drain_sse_blocks framing and any tail-flush handling.
rg -nP -C10 '\bfn\s+drain_sse_blocks\b' --type=rs
rg -nP -C4 'drain_sse_blocks|append_utf8_chunk' --type=rs -g '!provider-deepseek/**'

Repository: iii-hq/workers

Length of output: 214


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate drain_sse_blocks and append_utf8_chunk =="
rg -n -C10 '\b(drain_sse_blocks|append_utf8_chunk)\b' --glob '*.rs' || true

echo
echo "== Locate provider-deepseek upstream.rs relevant section =="
fd -a 'upstream\.rs' . || true
sed -n '1,220p' provider-deepseek/src/upstream.rs 2>/dev/null || true

Repository: iii-hq/workers

Length of output: 204


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate upstream.rs files =="
fd 'upstream\.rs$' . || true

echo
echo "== Inspect provider-deepseek upstream.rs (if present) =="
if [ -f provider-deepseek/src/upstream.rs ]; then
  sed -n '1,220p' provider-deepseek/src/upstream.rs
fi

echo
echo "== Search helpers and downstream implementations =="
for name in 'drain_sse_blocks' 'append_utf8_chunk'; do
  echo "--- $name ---"
  rg -n -C8 "$name" --glob '*.rs' || true
done

echo
echo "== Search for explicit tail/SSE blank-line handling =="
rg -n -C4 'DONE|sender|drop|recv|blank|tail|flush|empty_line|\\\\n\\\\n|SSE' --glob '*.rs' || true

Repository: iii-hq/workers

Length of output: 8853


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Repository files mentioning llm_router and SSE transport =="
rg -n 'llm_router|sse_transport|drain_sse_blocks|append_utf8_chunk' . || true

echo
echo "== Package definitions that may define the external crate =="
for f in Cargo.toml Cargo.lock llm-route Cargo.toml; do
  [ -f "$f" ] && { echo "--- $f ---"; rg -n 'llm_router|llm-route|llm_router' "$f" -C3 || true; }
done

echo
echo "== All Rust files under llm-router if present =="
fd -a '\.rs$' llm-router 2>/dev/null | head -200 || true

echo
echo "== Search SSE transport implementations by path/name =="
rg -n 'pub\s+struct|impl|fn\s+drain|fn\s+append|event-stream|data:' llm-router 2>/dev/null || true

Repository: iii-hq/workers

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== llm-router sse_transport implementation =="
sed -n '1,175p' llm-router/src/provider_scaffold/sse_transport.rs

echo
echo "== Any close-stream tail-flush tests in llm-router =="
sed -n '137,200p' llm-router/src/provider_scaffold/sse_transport.rs

Repository: iii-hq/workers

Length of output: 6848


Flush the final SSE block before sending Done.

drain_sse_blocks only drains complete \n\n-terminated blocks, so a connection close can leave the last data: {...} line in buf. This causes Done to include the previous state without the final payload. Flush any trailing SSE block in llm-router::provider_scaffold::sse_transport before Done.

🤖 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 `@provider-deepseek/src/upstream.rs` around lines 133 - 161, After the stream
ends in the while loop, before sending AssistantMessageEvent::Done with
build_final, process any remaining incomplete SSE block left in buf. The
drain_sse_blocks function only handles complete \n\n-terminated blocks, so a
connection close can leave a final data: {...} line unprocessed. Flush this
trailing block by decoding it through the same decode flow used in
drain_sse_blocks to update state with the final payload before build_final
constructs the Done message.

Comment on lines +91 to +126
let child = std::process::Command::new(&bin)
.arg("--no-update-check")
.arg("--config")
.arg(&config_path)
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn engine");

let url = format!("ws://127.0.0.1:{port}");
let probe = register_worker(&url, InitOptions::default());
let deadline = Instant::now() + Duration::from_secs(15);
loop {
let ready = probe
.trigger(TriggerRequest {
function_id: "engine::workers::list".into(),
payload: json!({}),
action: None,
timeout_ms: Some(1000),
})
.await
.is_ok();
if ready {
break;
}
assert!(
Instant::now() < deadline,
"engine did not become ready in 15s"
);
tokio::time::sleep(Duration::from_millis(250)).await;
}
probe.shutdown();

Some(Engine { url, child, dir })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Own the child process before the readiness poll, or a failed poll leaks the engine.

spawn_engine spawns the engine at Line 91 but builds Engine only at Line 125. If the assert at Lines 117-120 fires, Engine::drop never runs. The engine process keeps running and the temp dir stays on disk. Each failing run then leaks another process and holds its port. Construct Engine first so Drop owns the child on every panic path.

🔒️ Proposed fix
     let url = format!("ws://127.0.0.1:{port}");
+    // Own the child immediately: any panic below must still run Engine::drop.
+    let engine = Engine { url, child, dir };
     let probe = register_worker(&url, InitOptions::default());
     let deadline = Instant::now() + Duration::from_secs(15);
     loop {
         let ready = probe
             .trigger(TriggerRequest {
                 function_id: "engine::workers::list".into(),
                 payload: json!({}),
                 action: None,
                 timeout_ms: Some(1000),
             })
             .await
             .is_ok();
         if ready {
             break;
         }
         assert!(
             Instant::now() < deadline,
             "engine did not become ready in 15s"
         );
         tokio::time::sleep(Duration::from_millis(250)).await;
     }
     probe.shutdown();
 
-    Some(Engine { url, child, dir })
+    Some(engine)

url is used before the move, so clone it for the Engine field or read it back from engine.url.

🤖 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 `@provider-deepseek/tests/integration.rs` around lines 91 - 126, Update
spawn_engine to construct the Engine owner immediately after spawning the child
and before the readiness polling loop, so Engine::drop handles failed readiness
assertions and cleans up the process and temporary directory. Preserve the
existing URL and child ownership, cloning the URL or reusing engine.url as
needed before register_worker and the final return.

Comment on lines +320 to +326
#[tokio::test(flavor = "multi_thread")]
async fn provider_registers_with_persisted_token_and_credential_gated_catalog() {
// A real key exported on the host would leak into the in-process router's
// env-var fallback and defeat the no-credential assertions below.
std::env::remove_var("DEEPSEEK_API_KEY");
let engine = engine_or_skip!();
let (router_iii, provider_iii) = boot_stack(&engine.url).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the crate edition and any other env mutation in the test suite.
fd -t f 'Cargo.toml' | xargs rg -n 'edition|rust-version'
rg -n --type=rust 'env::(set_var|remove_var)' -C2

Repository: iii-hq/workers

Length of output: 2295


🏁 Script executed:

#!/bin/bash
set -u

echo "Files:"
fd -t f 'Cargo.toml|integration.rs' . | rg 'provider-deepseek|Cargo.toml$' | head -50

echo
echo "provider-deepseek Cargo manifest:"
fd -t f '^Cargo.toml$' provider-deepseek -x sh -c 'echo "--- $1"; sed -n "1,80p" "$1"' sh {}

echo
echo "Env mutation occurrences:"
rg -n --type=rust 'env::(set_var|remove_var|var|var_os|set_remove|getenv|setenv)' -C2 . || true

echo
echo "Target test excerpt:"
sed -n '300,360p' provider-deepseek/tests/integration.rs

echo
echo "Relevant provider-deepseek tests:"
rg -n --type=rust '(remove_var|DEEPSEEK_API_KEY|boot_stack|provider_registers_with_persisted_token|credential|token)' provider-deepseek -C3 || true

Repository: iii-hq/workers

Length of output: 50371


Remove the process-global env mutation from this test.

std::env::remove_var("DEEPSEEK_API_KEY") changes the whole test binary, so a parallel #[tokio::test] thread can clear the key while another test resolves credentials. Use an explicit configuration write that pins the empty-credential state just for this test.

🤖 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 `@provider-deepseek/tests/integration.rs` around lines 320 - 326, Remove the
process-global std::env::remove_var call from
provider_registers_with_persisted_token_and_credential_gated_catalog. Configure
the test’s engine or router setup through the existing explicit configuration
mechanism to pin DEEPSEEK_API_KEY as empty for this test only, preserving the
no-credential assertions without affecting parallel tests.

@andersonleal
andersonleal merged commit e720799 into main Aug 4, 2026
18 checks passed
ytallo added a commit that referenced this pull request Aug 4, 2026
The provider-deepseek worker (#691) resolves its credential from the
DEEPSEEK_API_KEY env on the router, but the E2E workflows only forwarded
the anthropic/openai/zai secrets, so a deepseek subject or judge could
never discover models in CI. Declare the optional secret in the reusable
workflow, forward it from every caller (including the release pipeline's
E2E gate from #692), add the provider to the Rust cache workspaces, and
export it in both run steps.
ytallo added a commit that referenced this pull request Aug 4, 2026
)

The provider-deepseek worker (#691) resolves its credential from the
DEEPSEEK_API_KEY env on the router, but the E2E workflows only forwarded
the anthropic/openai/zai secrets, so a deepseek subject or judge could
never discover models in CI. Declare the optional secret in the reusable
workflow, forward it from every caller (including the release pipeline's
E2E gate from #692), add the provider to the Rust cache workspaces, and
export it in both run steps.
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.

2 participants