Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,8 @@ NEXT_PUBLIC_CLOUD_URL=
#OPENCODE_USER_AGENT=opencode-cli/1.0.0
#OPENCODE_CLIENT=cli
#OPENCODE_PROJECT=default
#OPENCODE_FREE_TIER_REQUEST_CONTRACT=off
#OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS=glob,grep,read

# Ollama Cloud quota scraping. Prefer configuring this per connection in
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OPENCODE_USER_AGENT` | `opencode` | `open-sse/executors/opencode.ts` | Default User-Agent used when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on and no per-provider `<PROVIDER>_USER_AGENT` override is set. Only applied to opencode executors. |
| `OPENCODE_CLIENT` | `desktop` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-client` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. |
| `OPENCODE_PROJECT` | `global` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-project` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. |
| `OPENCODE_FREE_TIER_REQUEST_CONTRACT` | _(unset)_ | `open-sse/executors/opencodeFreeTierContract.ts` | Set to `off` to stop adjusting the body of keyless OpenCode requests (streaming flag and tool list). Headers are still applied. Read per request, so a change takes effect immediately. |
| `OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS` | _(unset)_ | `open-sse/executors/opencodeFreeTierContract.ts` | Comma-separated EXTRA tool names to declare on a keyless OpenCode request, on top of the `bash`/`glob`/`grep`/`read` quartet the upstream always requires (those four are injected whether or not this is set, and a configured name never replaces them). At most 32 entries, `[A-Za-z_][A-Za-z0-9_-]{0,63}`; invalid entries are ignored. |
| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go `auth` cookie env var used before the shorter alias. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. |
| `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | Ollama Cloud settings URL used for quota scraping. Override for relays / test fixtures. |
| `OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` cookie used for settings-page quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. |
Expand Down
81 changes: 62 additions & 19 deletions open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import {
BaseExecutor,
type ExecuteInput,
Expand All @@ -12,7 +11,18 @@ import {
isThinkingMessageModel,
} from "../utils/reasoningContentInjector.ts";
import { runWithDirectFetchContext, runWithProxyContext } from "../utils/proxyFetch.ts";
import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
import {
forwardOpencodeClientHeaders,
DEFAULT_OPENCODE_USER_AGENT,
} from "../utils/opencodeHeaders.ts";
import {
attemptFor,
fingerprintRenamesFor,
prepareFreeTierRequest,
rebuildJsonFromForcedStream,
surfaceFromBaseUrl,
} from "./opencodeFreeTierContract.ts";
import { restoreFingerprintToolNames } from "../utils/opencodeFingerprint.ts";
import {
type AccountProxyConfig,
type RotatableAccount,
Expand Down Expand Up @@ -454,6 +464,30 @@ export class OpencodeExecutor extends BaseExecutor {
};
}

private finalizeForcedStream(
input: ExecuteInput,
result: ExecutorExecuteResult
): ExecutorExecuteResult {
const restored = (r: ExecutorExecuteResult): ExecutorExecuteResult =>
this.restoreFingerprintNames(input, r);
if (input.stream) return restored(result);
if (!("response" in result) || !result.response) return result;
const model = attemptFor(input.body)?.model;
if (!model) return restored(result);
const response = rebuildJsonFromForcedStream(result.response, this._requestFormat, model);
return restored(response === result.response ? result : { ...result, response });
}

private restoreFingerprintNames(
input: ExecuteInput,
result: ExecutorExecuteResult
): ExecutorExecuteResult {
const renameMap = fingerprintRenamesFor(input.body);
if (!renameMap || !("response" in result) || !result.response) return result;
const response = restoreFingerprintToolNames(result.response, renameMap);
return response === result.response ? result : { ...result, response };
}

async execute(input: ExecuteInput) {
this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model);

Expand Down Expand Up @@ -522,15 +556,18 @@ export class OpencodeExecutor extends BaseExecutor {
"OPENCODE",
`upstream empty rejection on direct account (${chatcmplId}), retrying once…`
);
return this.normalizeMuseSparkResponse(input, await super.execute(input));
return this.finalizeForcedStream(
input,
this.normalizeMuseSparkResponse(input, await super.execute(input))
);
}
log?.debug?.(
"OPENCODE",
"400 without error field, signature not matched on direct account — observing"
);
}
}
return this.normalizeMuseSparkResponse(input, single);
return this.finalizeForcedStream(input, this.normalizeMuseSparkResponse(input, single));
}

// This loop only ever dispatches through super.execute() (the HTTP request
Expand Down Expand Up @@ -660,7 +697,7 @@ export class OpencodeExecutor extends BaseExecutor {
}

this.markSuccess(account);
return this.normalizeMuseSparkResponse(input, result);
return this.finalizeForcedStream(input, this.normalizeMuseSparkResponse(input, result));
}

// The loop exhausted without a result. If it's because every remaining
Expand All @@ -674,7 +711,10 @@ export class OpencodeExecutor extends BaseExecutor {
}

// All accounts returned 429 (or errored) — surface the last response.
return this.normalizeMuseSparkResponse(input, lastResult ?? (await super.execute(input)));
return this.finalizeForcedStream(
input,
this.normalizeMuseSparkResponse(input, lastResult ?? (await super.execute(input)))
);
} finally {
this._requestFormat = null;
}
Expand Down Expand Up @@ -749,7 +789,7 @@ export class OpencodeExecutor extends BaseExecutor {
userAgent:
process.env[envUAKey]?.trim() ||
process.env.OPENCODE_USER_AGENT?.trim() ||
"opencode",
DEFAULT_OPENCODE_USER_AGENT,
client: process.env.OPENCODE_CLIENT?.trim() || "desktop",
project: process.env.OPENCODE_PROJECT?.trim() || "global",
};
Expand All @@ -776,17 +816,10 @@ export class OpencodeExecutor extends BaseExecutor {
});
}

// Muse's Responses endpoint rejects the short conversation fingerprint used
// by the Chat endpoint in practice. Keep the workaround scoped to Muse.
if (
this._requestFormat === "openai-responses" &&
model.startsWith("muse-spark") &&
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
headers["x-opencode-session"] || ""
)
) {
headers["x-opencode-session"] = randomUUID();
}
// The Muse Responses workaround that forced a UUID session here is gone: the shape it
// produced is exactly what the upstream now refuses, and the canonical session it used
// to overwrite is accepted on that surface (measured 2026-09-17, 200 on
// muse-spark-1.3-contributor-free via /v1/responses).

void model;

Expand Down Expand Up @@ -914,6 +947,16 @@ export class OpencodeExecutor extends BaseExecutor {
if (isThinkingMessageModel(model)) {
modifiedBody = injectReasoningContentForThinkingModel(modifiedBody);
}
return modifiedBody;
const surface = surfaceFromBaseUrl(this.config?.baseUrl);
const { body: contractBody } = prepareFreeTierRequest(
modifiedBody,
this._requestFormat,
surface,
this.config?.id || this.provider || "opencode",
model,
undefined,
typeof body === "object" && body !== null ? (body as object) : undefined
);
return contractBody;
}
}
Loading