feat: multi-protocol model provider (Anthropic + OpenAI-compat) — support LiteLLM and RITS from main - #159
Conversation
… design (#157) Design for generalizing the harness model-synthesis wrapper to dispatch on a SH_MODEL_API protocol selector (anthropic | openai-completions | openai-responses), delegating to Pi's existing providers, with configurable base URL / headers / auth. Unblocks RITS/vLLM/OpenAI-compatible endpoints from main; Anthropic + LiteLLM paths unchanged (default). Status: Accepted. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: cwiklik <cwiklikj@gmail.com>
…#157) Generalize requireModel to select the wire protocol via SH_MODEL_API (anthropic | openai-completions | openai-responses), delegating to Pi's existing providers. Adds synthesizeOpenAICompletionsModel (baseUrl + SH_MODEL_HEADERS + SH_MODEL_AUTH bearer/custom-header/none) so RITS/vLLM/ OpenAI-compatible endpoints work from main; openai-responses is a defined-but- deferred stub. applyModelGateway now no-ops for non-anthropic models (they carry their own baseUrl/headers/auth). toolChoiceExtension emits the object form for Anthropic and the string "auto" for OpenAI. Default (registry, or SH_MODEL_CUSTOM=1 without SH_MODEL_API) is unchanged; the anthropic base URL now also accepts SH_MODEL_BASE_URL as a fallback. Tests: run-turn-model (openai synth, base-url fallback, custom-header strip, validation errors, anthropic-default regression) + new tool-choice-extension (object vs string, no-override, inert-without-custom). 23 pass; tsc --noEmit clean. Full suite: only pre-existing Redis-ECONNREFUSED integration tests fail. Implements docs/specs/2026-08-20-multi-protocol-model-provider-design.md. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: cwiklik <cwiklikj@gmail.com>
Header string values support ${VAR} env interpolation so a secret header value
(e.g. RITS's RITS_API_KEY) can be supplied via a secretKeyRef env var —
{"RITS_API_KEY":"${RITS_API_KEY}"} — instead of an inline literal in the
manifest. Unset ${VAR} → empty string. Needed for the OpenAI-compat custom-header
auth path (RITS) to consume the cluster secret cleanly.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: cwiklik <cwiklikj@gmail.com>
Add the OpenAI-compatible path (SH_MODEL_API=openai-completions + SH_MODEL_BASE_URL/
OPENAI_BASE_URL + custom-header auth via SH_MODEL_HEADERS ${VAR} indirection) to the
'Choosing the model' how-to in README-k8s/kind/ocp, alongside the existing Anthropic-
compatible instructions. Uses the RITS/Granite example validated on-cluster.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: cwiklik <cwiklikj@gmail.com>
pdettori
left a comment
There was a problem hiding this comment.
Summary
Clean, well-scoped generalization: dispatch on SH_MODEL_API in requireModel, a new synthesizeOpenAICompletionsModel, a protocol-aware toolChoiceExtension (object vs string auto), and a correct applyModelGateway no-op guard for non-Anthropic APIs. The Anthropic default path is preserved byte-for-byte, ${VAR} header interpolation keeps secrets out of manifests, and the 24 unit tests cover synthesis, base-URL fallback, custom-header strip, interpolation, and validation errors. Docs (k8s/kind/ocp READMEs + design spec) match the code.
Areas reviewed: TypeScript (run-turn, tool-choice-extension), tests, docs. Commits: 4, all DCO signed-off. Agent/IDE config: none.
CI status — 2 failing, both pre-existing and unrelated to this PR
checkdies atBuild pi-fork (type declarations)with the samesupportsReasoningEffort/OpenAIResponsesCompattype error that also fails onmain(run 32408167817). This PR touches neither pi-fork nor the generated types.trivy-scanflags CRITICAL/HIGH vulns in pnpmnode_modules; this PR changes nopackage.json/lockfile.
check fails at the pi-fork build step, the Typecheck and Test steps never run — so CI never actually executed the 24 tests or tsc --noEmit. The "24 green / tsc clean" evidence is locally-verified only. Worth merging with eyes open until the pre-existing pi-fork build issue is resolved.
Approving on the code's merit — the two inline notes are non-blocking suggestions.
Assisted-By: Claude Code
| } | ||
| const contextWindow = Number(env.SH_MODEL_CONTEXT_WINDOW) || 131072; | ||
| const maxTokens = Number(env.SH_MODEL_MAX_TOKENS) || 8192; | ||
| const auth = env.SH_MODEL_AUTH ?? "bearer"; |
There was a problem hiding this comment.
suggestion — SH_MODEL_AUTH isn't validated. An unrecognized value (a typo like custom_header, or Bearer with a capital) silently falls through to bearer behavior — the Authorization strip and placeholder-key seed on lines 147–152 never fire, producing a confusing downstream auth failure rather than a clear error. SH_MODEL_API errors on unknown values (see requireModel); consider validating this enum the same way for symmetry.
| // SDK's default Authorization Bearer so an unknown/empty Bearer isn't sent. pi's openai client | ||
| // still requires a non-empty api key even when the Bearer is unused, so seed a placeholder. | ||
| headers.Authorization = null; | ||
| if (!env.OPENAI_API_KEY) process.env.OPENAI_API_KEY = "unused"; |
There was a problem hiding this comment.
suggestion — this reads env.OPENAI_API_KEY but mutates the global process.env.OPENAI_API_KEY. When a caller passes a custom env (not process.env), the check and the write target different objects, so a caller supplying an env without the key pollutes the real process.env with "unused". The tests already work around this by passing OPENAI_API_KEY: "present". It's necessary because pi resolves the key from process.env at request time — worth a one-line comment noting the intentional read-env / write-global split (or seeding onto the passed env where feasible).
Summary
Generalize the harness model wiring so a leaf/turn can be driven against any of the common LLM wire protocols — Anthropic Messages, OpenAI Chat Completions, (OpenAI Responses, deferred) — selected per model via
SH_MODEL_API, delegating to Pi's existing providers. Unblocks OpenAI-compatible endpoints (IBM RITS, vLLM, OpenAI, Azure, most OSS gateways) frommain; the Anthropic + LiteLLM paths are unchanged.Closes #157. Design:
docs/specs/2026-08-20-multi-protocol-model-provider-design.md.What changed
requireModeldispatches onSH_MODEL_API(anthropicdefault |openai-completions|openai-responsesstub). NewsynthesizeOpenAICompletionsModelbuilds aModel<"openai-completions">withbaseUrl+headers, delegating to Pi'sopenai-completionsprovider.SH_MODEL_BASE_URL(falls back toANTHROPIC_BASE_URL/OPENAI_BASE_URL),SH_MODEL_HEADERS(JSON; string values support${VAR}interpolation so a secret flows in via asecretKeyRefenv, no inline literal),SH_MODEL_AUTH(bearer|custom-header|none).applyModelGatewayno-ops for non-Anthropic models (they carry their own baseUrl/headers/auth).toolChoiceExtensionis protocol-aware: Anthropic object{type:"auto"}vs OpenAI string"auto"(vLLM/RITS reject the object).Backward compatible: default (registry, or
SH_MODEL_CUSTOM=1withoutSH_MODEL_API) is byte-for-byte the Anthropic behavior shipped today.Verification
run-turn-model+ newtool-choice-extension) — OpenAI synth, base-URL fallback,${VAR}interpolation, custom-header Bearer strip, validation errors, Anthropic-default regression, object-vs-string tool_choice.tsc --noEmitclean.ibm-granite/granite-4.1-8b):SH_MODEL_API=openai-completions+SH_MODEL_HEADERS={"RITS_API_KEY":"${RITS_API_KEY}"}+SH_MODEL_AUTH=custom-header→ auth succeeded (secret via secretKeyRef).tool_choice="auto"(string form) sent, no vLLM rejection./turnranhostnameand returned the real pod name (unguessable) → structured tool call executed,stopReason:stop.Follow-ups (not in this PR)
/tmpOpenAI wiring fork (superseded by this).docs/specs/README.md(maintainer call).openai-responsessynthesizer (deferred; all current targets areopenai-completions).Assisted-By: Claude Code