Skip to content
Open
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
8 changes: 2 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Kleis

Single OAuth account proxy for coding agents. One base URL that stores OAuth credentials centrally, refreshes tokens automatically, and routes requests to Copilot/Codex/Claude through provider-compatible proxy endpoints.
Single OAuth account proxy for coding agents. One base URL that stores OAuth credentials centrally, refreshes tokens automatically, and routes requests to Codex and Claude through provider-compatible proxy endpoints.

## Architecture

Expand All @@ -13,7 +13,7 @@ Single OAuth account proxy for coding agents. One base URL that stores OAuth cre

## Database Schema (4 tables)

- **`provider_accounts`**: OAuth credentials per provider (copilot/codex/claude). Includes access/refresh tokens, expiry, primary flag, metadata JSON, distributed refresh lock fields.
- **`provider_accounts`**: OAuth credentials per provider (codex/claude). Includes access/refresh tokens, expiry, primary flag, metadata JSON, distributed refresh lock fields.
- **`api_keys`**: Proxy auth keys (`kleis_*` format). Provider and model scope arrays. Each key gets a `modelsDiscoveryToken` for scoped model URLs.
- **`oauth_states`**: Ephemeral records for in-flight OAuth flows.
- **`request_usage_buckets`**: Minute-bucketed analytics. Tracks request/success/error counts and latency per key+account+provider+endpoint.
Expand All @@ -26,15 +26,11 @@ All require `Authorization: Bearer <kleis_api_key>`:

| Route | Provider | Upstream |
|---|---|---|
| `POST /copilot/v1/chat/completions` | Copilot | GitHub Copilot chat completions |
| `POST /copilot/v1/responses` | Copilot | GitHub Copilot responses API |
| `POST /openai/v1/responses` | Codex | ChatGPT Codex responses API |
| `POST /anthropic/v1/messages` | Claude | Anthropic messages API |

## Provider Details

**Copilot**: GitHub device flow OAuth. Derives vision/initiator headers from message content. Supports enterprise domain override.

**Codex**: OpenAI browser (PKCE) or headless (device) flow. Injects Codex-specific headers (`ChatGPT-Account-Id`, `originator`). Strips unsupported params.

**Claude**: Anthropic PKCE flow (max or console mode). Full Claude Code identity emulation: system prompt rewriting, tool name prefixing/stripping (`mcp_` prefix), beta header injection, user-agent spoofing. Handles both streaming SSE transformation and non-streaming JSON rewriting.
Expand Down
2 changes: 1 addition & 1 deletion Caddyfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
kleis.rex.wf {
@streaming path /openai/v1/* /anthropic/v1/* /copilot/v1/*
@streaming path /openai/v1/* /anthropic/v1/*

handle @streaming {
reverse_proxy 127.0.0.1:3003 {
Expand Down
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="./assets/hero.png" alt="Kleis" width="100%" />
</p>

OAuth account proxy for [OpenCode](https://github.com/anomalyco/opencode). One base URL for Copilot, Codex, and Claude.
OAuth account proxy for [OpenCode](https://github.com/anomalyco/opencode). One base URL for Codex and Claude.

> [!NOTE]
> "Kleis" is named from the idea of a key that can unlock many paths with one handle.
Expand All @@ -13,7 +13,7 @@ Re-authenticating OAuth accounts across clients and machines is painful. Kleis s

## How it works

Each provider has its own proxy adapter because none of them behave the same way. Copilot needs vision/initiator headers derived from message content analysis. Codex rejects certain params and requires instruction injection. Claude needs tool name prefixing, system identity rewriting, beta header merging, and streaming response transformation to strip those prefixes back out.
Each provider has its own proxy adapter because none of them behave the same way. Codex rejects certain params and requires instruction injection. Claude needs tool name prefixing, system identity rewriting, beta header merging, and streaming response transformation to strip those prefixes back out.

`GET /api.json` serves a models.dev-compatible registry that merges upstream model data with Kleis routing info, so OpenCode auto-discovers everything without manual model config.

Expand All @@ -27,8 +27,6 @@ There's also minute-bucketed request analytics across both API keys and provider
|---|---|---|
| `POST /openai/v1/responses` | Codex | Responses API |
| `POST /anthropic/v1/messages` | Claude | Messages API |
| `POST /copilot/v1/chat/completions` | Copilot | Chat Completions |
| `POST /copilot/v1/responses` | Copilot | Responses API |

---

Expand Down Expand Up @@ -62,7 +60,6 @@ Admin panel lives at `http://localhost:3003/admin/`.
## OAuth flows

- Codex: browser callback code flow or headless device flow.
- Copilot: device flow.
- Claude: authorization code flow (`claude.ai` or `console.anthropic.com` mode).

After connecting accounts, set one primary account per provider.
Expand Down
6 changes: 0 additions & 6 deletions assets/hero.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
--text-tertiary: #5c5853;
--amber: #d4a24e;
--amber-dim: #b8882e;
--copilot: #79c0ff;
--codex: #a78bfa;
--claude: #fb923c;
--font-display: "Lexend", sans-serif;
Expand Down Expand Up @@ -96,10 +95,6 @@
color: var(--codex);
border-color: rgba(167, 139, 250, 0.2);
}
.node.copilot {
color: var(--copilot);
border-color: rgba(121, 192, 255, 0.2);
}
.node.claude {
color: var(--claude);
border-color: rgba(251, 146, 60, 0.2);
Expand All @@ -120,7 +115,6 @@
<span class="arrow">&rarr;</span>
<div class="node highlight">kleis</div>
<span class="arrow">&rarr;</span>
<div class="node copilot">copilot</div>
<div class="node claude">claude</div>
<div class="node codex">codex</div>
<div class="node more">...</div>
Expand Down
Binary file modified assets/hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 0 additions & 6 deletions assets/social.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
--text-tertiary: #5c5853;
--amber: #d4a24e;
--amber-dim: #b8882e;
--copilot: #79c0ff;
--codex: #a78bfa;
--claude: #fb923c;
--font-display: "Lexend", sans-serif;
Expand Down Expand Up @@ -96,10 +95,6 @@
color: var(--codex);
border-color: rgba(167, 139, 250, 0.2);
}
.node.copilot {
color: var(--copilot);
border-color: rgba(121, 192, 255, 0.2);
}
.node.claude {
color: var(--claude);
border-color: rgba(251, 146, 60, 0.2);
Expand All @@ -120,7 +115,6 @@
<span class="arrow">&rarr;</span>
<div class="node highlight">kleis</div>
<span class="arrow">&rarr;</span>
<div class="node copilot">copilot</div>
<div class="node claude">claude</div>
<div class="node codex">codex</div>
<div class="node more">...</div>
Expand Down
Binary file modified assets/social.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 2 additions & 6 deletions public/admin/app-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {

const DEFAULT_KEY_USAGE_WINDOW_MS = 24 * 60 * 60 * 1000;
const ADMIN_TOKEN_STORAGE_KEY = "kleis_admin_token";
const PROVIDER_ORDER = ["copilot", "codex", "claude"];
const PROVIDER_ORDER = ["codex", "claude"];

const readPersistedToken = () =>
localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY) || "";
Expand Down Expand Up @@ -1159,7 +1159,6 @@ async function copyToClipboard(text, btn) {

function updateOAuthProviderUI() {
const p = $("#oauth-provider").value;
$("#oauth-copilot-opts").style.display = p === "copilot" ? "block" : "none";
$("#oauth-codex-opts").style.display = p === "codex" ? "block" : "none";
$("#oauth-claude-opts").style.display = p === "claude" ? "block" : "none";
}
Expand All @@ -1172,10 +1171,7 @@ async function startOAuth() {

try {
const body = {};
if (provider === "copilot") {
const ed = $("#oauth-enterprise-domain").value.trim();
if (ed) body.options = { enterpriseDomain: ed };
} else if (provider === "codex") {
if (provider === "codex") {
body.options = { mode: $("#oauth-codex-mode").value };
} else if (provider === "claude") {
body.options = { mode: $("#oauth-claude-mode").value };
Expand Down
7 changes: 3 additions & 4 deletions public/admin/app-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ function claudeLimitsBody(data) {
}

function accountTrackingHtml(account) {
if (account.provider === "copilot") return "";
const tracking = account.quota;
if (!tracking) {
return `<div class="limits">
Expand Down Expand Up @@ -760,7 +759,7 @@ function renderDashKpis(m, pm) {
label: "cache hit",
value: `${cr}%`,
delta: dashDelta(cr, prevCr),
accent: "var(--copilot)",
accent: "var(--amber)",
},
{
label: "avg latency",
Expand Down Expand Up @@ -864,7 +863,7 @@ function tokenSeriesExtractor(bucket) {
return [
{ value: m.inputTokens, color: "var(--amber)", label: "input" },
{ value: m.outputTokens, color: "var(--green)", label: "output" },
{ value: m.cacheReadTokens, color: "var(--copilot)", label: "cache read" },
{ value: m.cacheReadTokens, color: "var(--amber)", label: "cache read" },
{
value: m.cacheWriteTokens,
color: "var(--text-secondary)",
Expand Down Expand Up @@ -953,7 +952,7 @@ function renderDashboard(data) {
html += `<div class="dash-card"><div class="dash-chart-title">request volume</div>${reqChart}<div class="dash-legend"><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--green)"></span>success</span><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--red)"></span>proxy fail</span><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--text-secondary)"></span>upstream/other fail</span><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--amber)"></span>429</span></div></div>`;
}
if (tokChart) {
html += `<div class="dash-card"><div class="dash-chart-title">token usage</div>${tokChart}<div class="dash-legend"><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--amber)"></span>input</span><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--green)"></span>output</span><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--copilot)"></span>cache read</span><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--text-secondary)"></span>cache write</span></div></div>`;
html += `<div class="dash-card"><div class="dash-chart-title">token usage</div>${tokChart}<div class="dash-legend"><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--amber)"></span>input</span><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--green)"></span>output</span><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--amber)"></span>cache read</span><span class="dash-legend-item"><span class="dash-legend-dot" style="background:var(--text-secondary)"></span>cache write</span></div></div>`;
}
html += "</div>";
}
Expand Down
34 changes: 4 additions & 30 deletions public/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -265,23 +265,10 @@
<div class="field">
<label class="field-label" for="oauth-provider">Provider</label>
<select id="oauth-provider" class="field-input">
<option value="copilot">Copilot (GitHub)</option>
<option value="codex">Codex (OpenAI)</option>
<option value="claude">Claude (Anthropic)</option>
</select>
</div>
<div id="oauth-copilot-opts" class="field">
<label class="field-label" for="oauth-enterprise-domain">
Enterprise Domain (optional)
</label>
<input
id="oauth-enterprise-domain"
class="field-input"
type="text"
placeholder="github.mycompany.com"
>
<div class="field-hint">Leave blank for github.com</div>
</div>
<div id="oauth-codex-opts" class="field" style="display:none">
<label class="field-label" for="oauth-codex-mode">
OAuth Mode
Expand Down Expand Up @@ -318,7 +305,6 @@
Provider
</label>
<select id="import-provider" class="field-input">
<option value="copilot">Copilot (GitHub)</option>
<option value="codex">Codex (OpenAI)</option>
<option value="claude">Claude (Anthropic)</option>
</select>
Expand Down Expand Up @@ -371,7 +357,7 @@
id="import-account-id"
class="field-input"
type="text"
placeholder="chatgpt-org-id or github user id"
placeholder="provider-specific account id"
>
</div>
<div class="field">
Expand Down Expand Up @@ -430,10 +416,6 @@
Leave empty for all providers.
</div>
<div class="scope-grid">
<label class="scope-check">
<input type="checkbox" value="copilot" class="key-scope-provider">
copilot
</label>
<label class="scope-check">
<input type="checkbox" value="codex" class="key-scope-provider">
codex
Expand All @@ -460,7 +442,7 @@
<textarea
id="key-model-scopes"
class="field-input field-input-compact"
placeholder="github-copilot/gpt-5-mini, anthropic/claude-sonnet-4-5"
placeholder="gpt-5.6-luna, anthropic/claude-sonnet-4-5"
rows="2"
></textarea>
<div class="field-hint">
Expand Down Expand Up @@ -515,7 +497,7 @@
<textarea
id="edit-account-metadata"
class="field-input"
placeholder='{"githubLogin":"octocat"}'
placeholder='{"chatgptAccountId":"org_123"}'
></textarea>
<div class="field-hint">
Must be a JSON object. Leave empty to clear metadata.
Expand Down Expand Up @@ -555,14 +537,6 @@
Leave empty for all providers.
</div>
<div class="scope-grid">
<label class="scope-check">
<input
type="checkbox"
value="copilot"
class="edit-key-scope-provider"
>
copilot
</label>
<label class="scope-check">
<input
type="checkbox"
Expand Down Expand Up @@ -596,7 +570,7 @@
<textarea
id="edit-key-model-scopes"
class="field-input field-input-compact"
placeholder="github-copilot/gpt-5-mini, anthropic/claude-sonnet-4-5"
placeholder="gpt-5.6-luna, anthropic/claude-sonnet-4-5"
rows="2"
></textarea>
<div class="field-hint">
Expand Down
6 changes: 0 additions & 6 deletions public/admin/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
--green-dim: rgba(74, 222, 128, 0.15);
--red: #f87171;
--red-dim: rgba(248, 113, 113, 0.15);
--copilot: #79c0ff;
--copilot-dim: rgba(121, 192, 255, 0.12);
--codex: #a78bfa;
--codex-dim: rgba(167, 139, 250, 0.12);
--claude: #fb923c;
Expand Down Expand Up @@ -573,10 +571,6 @@ body {
border-radius: var(--radius-sm);
line-height: 1;
}
.badge-copilot {
color: var(--copilot);
background: var(--copilot-dim);
}
.badge-codex {
color: var(--codex);
background: var(--codex-dim);
Expand Down
2 changes: 1 addition & 1 deletion src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
uniqueIndex,
} from "drizzle-orm/sqlite-core";

export const providers = ["copilot", "codex", "claude"] as const;
export const providers = ["codex", "claude"] as const;
export type Provider = (typeof providers)[number];

export const providerAccounts = sqliteTable(
Expand Down
19 changes: 0 additions & 19 deletions src/http/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
readCodexSessionId,
} from "../../providers/proxies/codex-proxy";
import { tryProxyCodexWebSocket } from "../../providers/proxies/codex-websocket";
import { prepareCopilotProxyRequest } from "../../providers/proxies/copilot-proxy";
import type { UsageRequestSource } from "../../usage/request-outcome";
import {
isTokenUsagePopulated,
Expand Down Expand Up @@ -296,24 +295,6 @@ const proxyRequest = async (
break;
}

case "copilot": {
const copilotProxy = prepareCopilotProxyRequest({
endpoint: route.endpoint,
requestUrl,
headers,
bodyText: requestBody,
bodyJson: requestBodyJson,
githubAccessToken: account.refreshToken,
metadata:
account.metadata?.provider === "copilot" ? account.metadata : null,
onTokenUsage: usageRecorder.onTokenUsage,
});
upstreamUrl = copilotProxy.upstreamUrl;
requestBody = copilotProxy.bodyText;
responseTransformer = copilotProxy.transformResponse;
break;
}

case "claude": {
const claudeProxy = prepareClaudeProxyRequest({
requestUrl,
Expand Down
6 changes: 1 addition & 5 deletions src/http/utils/request-timeout.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
const streamingProxyPathPrefixes = [
"/openai/v1/",
"/anthropic/v1/",
"/copilot/v1/",
] as const;
const streamingProxyPathPrefixes = ["/openai/v1/", "/anthropic/v1/"] as const;

export const resolveRequestIdleTimeout = (pathname: string): number | null => {
for (const prefix of streamingProxyPathPrefixes) {
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ app.route("/admin", adminApi);

app.use("/openai/v1/*", requireProxyApiKey);
app.use("/anthropic/v1/*", requireProxyApiKey);
app.use("/copilot/v1/*", requireProxyApiKey);
app.route("/", proxyRoutes);

export default {
Expand Down
7 changes: 0 additions & 7 deletions src/providers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,6 @@ export const CODEX_WEBSOCKET_BETA_HEADER = "responses_websockets=2026-02-06";
export const CODEX_ORIGINATOR = "opencode";
export const CODEX_USER_AGENT = "opencode";

// https://github.com/anomalyco/opencode/blob/d848c9b6a32f408e8b9bf6448b83af05629454d0/packages/opencode/src/plugin/copilot.ts#L121-L131
// https://github.com/badlogic/pi-mono/blob/5c0ec26c28c918c5301f218e8c13fcc540d8e3a4/packages/ai/src/providers/github-copilot-headers.ts#L27-L34
export const COPILOT_DEFAULT_API_BASE_URL = "https://api.githubcopilot.com";
export const COPILOT_OPENAI_INTENT = "conversation-edits";
export const COPILOT_INITIATOR_HEADER = "x-initiator";
export const COPILOT_VISION_HEADER = "Copilot-Vision-Request";

export const ANTHROPIC_API_BASE_URL = "https://api.anthropic.com";
// https://github.com/anomalyco/opencode/blob/d848c9b6a32f408e8b9bf6448b83af05629454d0/packages/opencode/src/provider/provider.ts#L124-L127
// https://github.com/badlogic/pi-mono/blob/5c0ec26c28c918c5301f218e8c13fcc540d8e3a4/packages/ai/src/providers/anthropic.ts#L536
Expand Down
Loading