Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ about it. Read what your agent produced before you submit it.
`swisscode` is a **launcher**. It resolves a profile (provider + credential +
per-tier models + flags), builds a child environment, and `execve`s the real
coding CLI — `claude`, `kilo`, or `opencode` — replacing its own process image.
No proxy, no daemon, nothing left running.
A launch leaves nothing running: no proxy, no daemon, no background process.

The one exception is opt-in and off the launch path — `swisscode config proxy`
runs a local gateway that fails over between profiles when a provider returns
529. It is foreground-only, reached solely through a dynamic import, and
invariant 1 below still holds: the launch path itself never touches a socket.

TypeScript, published as compiled JavaScript. Node >= 22. Four runtime
dependencies, all reachable only from the Ink wizard.
Expand Down
63 changes: 59 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<a href="https://github.com/jellologic/swisscode/actions/workflows/ci.yml"><img src="https://github.com/jellologic/swisscode/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src="https://img.shields.io/node/v/swisscode?logo=node.js&logoColor=white&color=5fa04e" alt="node current">
<img src="https://img.shields.io/npm/l/swisscode?color=3da639" alt="MIT license">
<img src="https://img.shields.io/badge/no%20proxy-no%20daemon-8957e5" alt="no proxy, no daemon">
<img src="https://img.shields.io/badge/launch-nothing%20left%20running-8957e5" alt="a launch leaves nothing running">
<a href="AGENTS.md"><img src="https://img.shields.io/badge/agent--written%20PRs-welcome-2f81f7" alt="agent-written PRs welcome"></a>
</p>

Expand All @@ -25,18 +25,24 @@ exactly like `claude`, only pointed at [OpenRouter](https://openrouter.ai),
Anthropic-compatible endpoint.

Unlike a router/proxy or a desktop GUI, swisscode is a **launcher**: it sets the
right environment and `exec`s the real CLI, so there is **no proxy, no daemon, no
background process** — and it fixes third-party correctness bugs (like the
right environment and `exec`s the real CLI, so **a launch leaves nothing
running** — no proxy, no daemon, no background process between you and your
agent. It also fixes third-party correctness bugs (like the
[silent 200K → 1M context downgrade](#extended-context-1m)) that a log-reader or
proxy structurally cannot.

There is one thing you can start on purpose:
[`swisscode config proxy`](#gateway) runs a local gateway that fails over
between profiles when a provider is overloaded. It is opt-in, runs in the
foreground, and is not on the launch path.

- **Any provider** — OpenRouter, z.ai/GLM, Kimi, DeepSeek, Qwen, ModelScope, SiliconFlow, or a custom Anthropic-compatible endpoint.
- **Local models, no key** — [Ollama](#ollama) speaks the Anthropic Messages API natively, so `swisscode` points Claude Code at `localhost` with no proxy and nothing to sign up for.
- **Any agent** — Claude Code (default), [Kilo](https://kilo.ai) or [OpenCode](https://opencode.ai), selectable per profile or per run.
- **Named profiles &amp; per-directory bindings** — the right backend per repo, automatically.
- **Correctness fixes** — real 1M context (`[1m]`), catalog-driven auto-compaction, gateway compatibility flags.
- **A preflight `doctor`** — binary, endpoint, credential, models, real tool-calling probe, and the context window your local server actually loaded.
- **No proxy, no daemon, no GUI** — a single binary that `exec`s the real CLI, so nothing sits between you and your agent.
- **A launch leaves nothing running** — a single binary that `exec`s the real CLI, so nothing sits between you and your agent. The optional [gateway](#gateway) is the one process you start deliberately.

It replaces shell aliases like this:

Expand Down Expand Up @@ -538,6 +544,55 @@ shipped presets are tested against: no `/v1` suffix, no hand-typed `[1m]`, real
compatibility flags. Shipped presets stay read-only, and a custom provider cannot
shadow one.

## Gateway

`swisscode config proxy` runs a local gateway that sits in front of several
profiles and fails over between them. It exists for one failure a launcher
structurally cannot fix: a provider that is fine when you start and overloaded
twenty minutes later.

```sh
swisscode config proxy --profile work --fallback glm
swisscode config proxy --profile work --fallback glm,local --port 8787

# then point any agent at it
swisscode --cc-base-url http://127.0.0.1:8787
```

It has **no configuration of its own**. Every route is derived from the profiles
you already keep, through the same resolution a launch uses, so the gateway and
the launcher can never disagree about what "my work profile" means.

When the primary returns `429` or `529` it retries — honouring `retry-after` —
and then moves to the next profile. Failover **remaps the model by tier**: a
request for your `opus`-tier model reaches the fallback's `opus`-tier model, so
failing over to z.ai asks for `glm-5.2` rather than forwarding `claude-opus-5`
to a host that has never heard of it.

A few properties worth stating plainly:

- **Requests are forwarded byte for byte.** Anthropic signs `thinking` blocks
against the exact bytes it received, so a proxy that re-encodes a body
invalidates them. This one only re-serializes when it actually changes the
model, on failover.
- **A credential never crosses routes.** Each route carries its own account's
key, and a profile authenticated by a Claude *login* rather than a key
forwards your own credential untouched instead of substituting one.
- **`/v1/messages/count_tokens` is answered locally.** Claude Code calls it to
decide when to auto-compact, and Anthropic rejects it outright for
subscription tokens, so a gateway that forwards or 404s it breaks compaction
silently. The count is an estimate and is documented as one.
- **Foreground only.** Ctrl-C ends it. There is no daemon and no PID file — the
port bind is the mutex, exactly as it is for the web UI. It binds `127.0.0.1`
only.

`/health` lists the routes and `/usage` reports per-profile token totals; both
omit credentials. Totals are also printed on exit.

This is the one part of swisscode that keeps running. It is opt-in, it is not on
the launch path, and a plain `swisscode` launch is unaffected by whether it is
running.

## Agents

The **provider** is which model backend you talk to; the **agent** is which
Expand Down
22 changes: 18 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,18 @@ argv ─┬─> parse ──> select profile ──> apply overrides ──> bui
(process replaced)
```

There is no proxy, no daemon, no background process, and after `execve` no
swisscode. That single fact drives most of the design: anything that would make
the launch slower, heavier or less auditable is pushed off the launch path or
out of the project.
A launch involves no proxy, no daemon, no background process, and after `execve`
no swisscode. That single fact drives most of the design: anything that would
make the launch slower, heavier or less auditable is pushed off the launch path
or out of the project.

Note the scope of that claim. It is about the **launch path**, which is the part
`test/architecture.test.ts` actually enforces — a closure rooted at `src/cli.ts`
that may not import `node:http` or call `fetch`, and that is capped at 42
modules. `swisscode config proxy` starts a long-running local gateway, and is
legal for exactly the reason the web UI is: it is reached only through a dynamic
`import()`, so it never joins that closure. A launch still leaves nothing
running; a gateway is something you start on purpose and stop with Ctrl-C.

The tool's job is small; its **failure modes are expensive**. Sending a z.ai
token to OpenRouter, silently billing an Anthropic account because a stale
Expand Down Expand Up @@ -114,6 +122,12 @@ that an adapter meets it. Here the check is real in three places:
caught. Gated on the provider id rather than generalised into an "introspect a
provider" port method: one example is not enough to know that abstraction's
shape, and the second caller is what should define it.
- **`gateway/*`** — `swisscode config proxy`. `server` is the only module that
touches a socket; `table` derives routes from profiles, `dispatch` holds the
retry and failover policy, `tokens` estimates a count locally. The split is
the same one the web feature makes, for the same reason: a policy you can test
without a network is a policy that gets tested. Reached only through a dynamic
import, so it never joins the launch closure.
- **`claude-session/*`** — Claude subscription logins, which belong to the agent
rather than to us. Split three ways on purpose: `identity` reads *who* an
account is (a file read, no credential, no prompt, so listing is free),
Expand Down
26 changes: 20 additions & 6 deletions scripts/size-budget.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,27 @@ import { execFileSync } from 'node:child_process'
* which is the same as not having one. Raising it should be a visible line in a
* diff with a reason attached.
*
* LOWERED from 260 when the artifact fell to ~118 kB — stripping comments from
* the emitted JS, minifying dist/ui.js, and swapping react-dom for preact/compat
* in the browser bundle. A ceiling with more slack beneath it than artifact
* above it is not a budget; it is a number. 150 keeps ~27% headroom, which is
* room for an honest feature and not room for a silent regression.
* LOWERED from 260 to 150 when the artifact fell to ~118 kB — stripping comments
* from the emitted JS, minifying dist/ui.js, and swapping react-dom for
* preact/compat in the browser bundle. A ceiling with more slack beneath it than
* artifact above it is not a budget; it is a number.
*
* RAISED to 175 for `config proxy`, the local gateway (+4.8 kB packed). Two
* things are worth recording about that number, because the 150 it replaced had
* quietly stopped doing its job:
*
* The artifact had drifted from ~118 kB to 147.7 kB — 2.3 kB, or 1.5%, under
* the ceiling — while this comment still claimed ~27% headroom. A budget that
* flush fails on the next honest change, which is exactly the reflexive-raise
* failure the paragraph above warns about; it had become that, unnoticed,
* because nothing re-reads a number that keeps passing.
*
* The gateway is the same trade as the web UI: everyone downloads it, most
* people never start it. 4.8 kB for failover across providers is a trade worth
* making once — and, like the web UI, it is the reason to keep measuring rather
* than an excuse to stop.
*/
const BUDGET_KB = 150
const BUDGET_KB = 175

// `--ignore-scripts` because `prepare` runs the whole build, and this script is
// meant to MEASURE the artifact, not rebuild it — in CI the build has already
Expand Down
86 changes: 86 additions & 0 deletions src/adapters/gateway/dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Retry and failover policy. Pure arithmetic and predicates — the decisions,
// not the requests.
//
// Split from server.ts on the same principle as the web feature, where routing
// lives in api.ts and only server.ts touches a socket: a policy you can test
// without a network is a policy that gets tested.

/**
* Statuses worth trying again.
*
* 529 is Anthropic's "overloaded" and is the reason this gateway exists — it
* arrived 33 times in four minutes on 2026-09-03 while a healthy second
* provider sat idle. 429 is included, but see `isTerminalRateLimit`.
*/
const RETRYABLE = Object.freeze([408, 429, 500, 502, 503, 504, 529])

export function isRetryable(status: number): boolean {
return RETRYABLE.includes(status)
}

/**
* A 429 that will still be a 429 in an hour.
*
* A spend cap and a rate limit share a status code and mean opposite things:
* one clears in seconds, the other needs a human with a credit card. Retrying
* the second is pure latency. The signal is weak — no header distinguishes
* them — so this reads the message rather than guessing, and errs toward
* retrying when unsure.
*/
export function isTerminalRateLimit(body: string): boolean {
return /insufficient|balance|quota|credit|recharge|billing|payment/i.test(body)
}

export type RetryPolicy = {
/** Attempts against one route before moving to the next. */
attempts: number
baseDelayMs: number
maxDelayMs: number
}

export const DEFAULT_POLICY: RetryPolicy = Object.freeze({
attempts: 3,
baseDelayMs: 500,
maxDelayMs: 8000,
})

/**
* How long to wait before the next attempt.
*
* A server that tells us when to come back beats any local guess, so
* `retry-after` wins outright — in both its numeric-seconds and HTTP-date
* forms. Otherwise exponential backoff with jitter, so that concurrent
* requests do not all retry on the same tick and rebuild the thundering herd
* the backoff exists to prevent.
*/
export function retryDelay(
attempt: number,
retryAfter: string | null,
policy: RetryPolicy = DEFAULT_POLICY,
random: () => number = Math.random,
): number {
if (retryAfter) {
const seconds = Number(retryAfter)
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.min(seconds * 1000, policy.maxDelayMs)
}
const at = Date.parse(retryAfter)
if (!Number.isNaN(at)) return Math.min(Math.max(at - Date.now(), 0), policy.maxDelayMs)
}
const backoff = policy.baseDelayMs * 2 ** attempt
return Math.min(backoff + random() * policy.baseDelayMs, policy.maxDelayMs)
}

/** Condense an upstream error body to one line worth logging. */
export function summarize(body: string): string {
try {
const error = (JSON.parse(body) as { error?: { type?: string; message?: string } }).error
if (error) {
const line = `${error.type ?? 'error'}: ${error.message ?? ''}`.trim()
if (line !== 'error:') return line
}
} catch {
// Not JSON. The raw excerpt below is more useful than a parse complaint.
}
return body.replace(/\s+/g, ' ').trim().slice(0, 300) || '(empty body)'
}
Loading