Skip to content

feat(wiring): OpenAI-compatible local-model adapter (Ollama / LM Studio / vLLM / …) - #7

Merged
telivity-otaip merged 1 commit into
mainfrom
feat/openai-compatible-adapter
Jun 16, 2026
Merged

feat(wiring): OpenAI-compatible local-model adapter (Ollama / LM Studio / vLLM / …)#7
telivity-otaip merged 1 commit into
mainfrom
feat/openai-compatible-adapter

Conversation

@telivity-otaip

Copy link
Copy Markdown
Collaborator

Summary

One adapter, many backends. createOpenAICompatibleCaller targets any HTTP server speaking the OpenAI-compatible /v1/chat/completions API — Ollama, LM Studio, vLLM, llama.cpp server, OpenRouter, Azure OpenAI. This unlocks ASIL for regulated industries (healthcare, gov, finance, defense) that can't send code to cloud APIs.

What ships

Adapter (in `packages/asil-runners/src/wiring.ts`):

```ts
createOpenAICompatibleCaller({
baseUrl: 'http://localhost:11434/v1',
apiKey: 'optional',
modelId: 'llama3.1:8b-instruct-q4_K_M',
estimateTokens: t => Math.ceil(t.length / 4), // optional override
}): LLMCaller

createOpenAICompatibleCodexCaller({ ... }): CodexCaller // for the adversarial gate
```

Shares a small internal POST helper with the existing `createCodexCaller` — same fetch pattern, same error-shape.

Env switching (run-a + run-b):

Var Effect
`ASIL_LLM_BASE_URL` Use local adapter for primary LLM
`ASIL_LLM_MODEL` Model id passed verbatim to the server
`ASIL_LLM_API_KEY` Optional Bearer
`ASIL_CODEX_BASE_URL` / `ASIL_CODEX_API_KEY` Same for adversarial gate

When `ASIL_LLM_BASE_URL` is unset, behaviour is unchanged from cloud-only deployments.

loadEnv relaxation: `ANTHROPIC_API_KEY` is no longer required when `ASIL_LLM_BASE_URL` is set. Air-gapped users don't have to set a dummy value.

Cost-controller graceful: `calculateCallCost` returns `Decimal(0)` when the model id isn't in the pricing table. Token caps still bite — wire cost is $0 in local mode, but token counts (from the server's `usage` field, or chars/4 fallback) still flow through.

What this changes for cloud users

Nothing. Without the env vars set, run-a and run-b construct the same Anthropic + OpenAI callers they always did. The adapter additions are pure-add.

Walkthrough

New `examples/local-llm.md` covers:

  • Ollama setup (full env block + `ollama pull` + verification)
  • LM Studio (port 1234 default)
  • vLLM (GPU server flag)
  • Mixed deployments — cloud-for-execution + local-for-adversarial-gate (and reverse)
  • Cost accounting in local mode (token caps still enforce, dollars report as $0)
  • When NOT to use local mode (production grinds against high-value codebases, same-family adversarial gate)

Tests

  • Before: 363 (47 + 45 + 165 + 54 + 52)
  • After: 371 (47 + 45 + 165 + 54 + 60). New tests in `wiring.test.ts`:
    • `createOpenAICompatibleCaller` — request shape (POST to `/chat/completions` with system+user messages), Authorization header presence/absence, token-usage parsing from server, chars/4 estimation fallback when usage absent, custom estimator override, trailing-slash baseUrl normalization, non-OK error handling
    • `createOpenAICompatibleCodexCaller` — single user message + `{content}` return shape (no token surface)
    • `loadEnv` — local-mode skips the ANTHROPIC_API_KEY throw
  • Build + typecheck clean across all 5 packages
  • No skips, no `.only`

Files

Modified:

  • `packages/asil-runners/src/wiring.ts` — `createOpenAICompatibleCaller` + `createOpenAICompatibleCodexCaller` + shared `postOpenAICompatible` helper; `loadEnv` local-mode skip
  • `packages/asil-runners/src/run-a.ts` — env-driven adapter selection
  • `packages/asil-runners/src/run-b.ts` — env-driven adapter selection
  • `packages/asil-cost-controller/src/cost-estimator.ts` — graceful $0 for non-tier model ids
  • `packages/asil-runners/src/tests/wiring.test.ts` — 8 new tests

New:

  • `examples/local-llm.md`

Updated docs:

  • Root `README.md` — new subsections for analyzer, local models, Python profile

Closes the four-PR sequence

This is the fourth of four merged PRs from the planning session:

🤖 Generated with Claude Code

Add createOpenAICompatibleCaller + createOpenAICompatibleCodexCaller
to wiring.ts. Targets any HTTP server speaking /v1/chat/completions —
Ollama, LM Studio, vLLM, llama.cpp server, OpenRouter, Azure OpenAI.
One adapter, many backends.

Env switching:
  - ASIL_LLM_BASE_URL → use the local adapter for the primary LLM
  - ASIL_LLM_MODEL → model id passed verbatim to the server
  - ASIL_LLM_API_KEY → optional Bearer (many local servers ignore auth)
  - ASIL_CODEX_BASE_URL / ASIL_CODEX_API_KEY → same for adversarial gate
  - Defaults to cloud Anthropic + OpenAI when env vars are unset

loadEnv() now skips the ANTHROPIC_API_KEY requirement when
ASIL_LLM_BASE_URL is set. Air-gapped / regulated deployments aren't
forced to set a dummy key.

Cost-controller graceful for non-tier model ids: calculateCallCost
returns Decimal(0) when the model isn't in the pricing table.
Token caps still enforce — wire cost is $0 in local mode but token
counts flow through (via server's `usage` field, or chars/4 estimate
when omitted).

run-a.ts and run-b.ts switch adapters automatically based on
ASIL_LLM_BASE_URL / ASIL_CODEX_BASE_URL.

Tests: +8 in asil-runners covering the new adapter (request shape,
auth header presence/absence, token estimation fallback, custom
estimator, trailing-slash normalization, error handling) + the
CodexCaller variant + loadEnv's local-mode skip.

Total project tests: 363 → 371.

Docs: new examples/local-llm.md walkthrough (Ollama / LM Studio /
vLLM / mixed-mode recipes + cost-accounting + when-NOT-to-use).
Root README updated with three subsections (analyzer, local models,
Python profile) flagging the new surfaces.
@telivity-otaip
telivity-otaip merged commit 288bfd9 into main Jun 16, 2026
@telivity-otaip
telivity-otaip deleted the feat/openai-compatible-adapter branch June 16, 2026 17:19
telivity-otaip added a commit that referenced this pull request Jun 17, 2026
#2) (#9)

The cost-controller only recorded executor token spend; self-review
(3 persona calls) and the adversarial gate were invisible to both the
budget cap and the reported total — System A could spend ~3x what the
checkpoint saw, and the kill switch couldn't fire mid-task. System B
(papa) recorded an aggregate only AFTER the thinker fan-out, with no
pre-flight check.

Changes:
- CodexCaller contract gains optional token fields; createCodexCaller
  parses OpenAI's usage block, createOpenAICompatibleCodexCaller
  estimates chars/4 when the server omits usage. The adversarial gate's
  spend was previously untracked AT THE SOURCE (the interface returned
  only { content }).
- SelfReviewResult + AdversarialReviewResult carry tokenUsage.
  selfReview aggregates the three persona calls; adversarialReview
  surfaces the codex call's tokens.
- loop.ts records every stage against the checkpoint: forceCheck before
  the self-review fan-out, recordAndCheck after self-review and after
  adversarial, kill→budget-exceeded on any over-budget result. The
  reported totalTokenUsage now sums executor + review + adversarial on
  ALL outcome paths (success and failure).
- papa.ts forceChecks before the thinker fan-out so a request already at
  the ceiling never launches N parallel calls.

The codex model id is free-form (not a ModelTier); cost-estimator
returns $0 for ids absent from the pricing table (PR #7), so tokens are
tracked even though the codex wire-cost line is $0. Adding codex pricing
is a follow-on.

Also adds docs/design/2026-06-17-criticals-sandbox-and-budget.md — the
design for this fix (#2) AND critical #1 (sandbox hardening, Level 1
chosen). #1 ships as a separate follow-on PR.

Tests: 380 → 384. New: cloud + local codex token surfacing, loop
total-token accounting includes review+adversarial. No skips.

Refs CODEX_REVIEW.md (#2).
telivity-otaip added a commit that referenced this pull request Jun 17, 2026
…) (#11)

#7 — DomainAnswerStore keyed answers by question TEXT only, so two
different files asking the same question (e.g. "What is the grace
period?") collapsed to one answer; answering it in file A silently
unblocked file B with A's answer. hashQuestion now takes (filePath,
text) and hashes the composite. Same wording in a different file is a
distinct question again.

#9 — Captured transcripts wrote full prompts/responses to disk
unredacted; they can carry pulled-in source and secrets. New
redactSecrets() masks known token shapes (sk-ant-, sk-/sk-proj-,
ghp_/gho_/…, github_pat_, AKIA/ASIA, AIza, xox*, Bearer values, and
SECRET_NAME=value assignments). instrumentLLMCaller/CodexCaller redact
by default (opt out with { redact: false }); the events file is created
mode 0600.

Tests: 387 → 400. New: hashQuestion file-scoping + path-normalization;
redactSecrets per token shape + non-secret passthrough; instrumented
callers redact by default and honor redact:false. No skips.

Refs Codex review #7, #9 (private KB).
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