Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
1171830
fix(claude-oauth): rewrite lexical trigger keywords
i1hwan Apr 10, 2026
1ab60b9
feat(proxy): add forwarding keyword rule registry
i1hwan Apr 11, 2026
e2269d8
feat(settings): add forwarding keyword settings API
i1hwan Apr 11, 2026
b49c427
feat(settings): add forwarding keyword settings editor
i1hwan Apr 11, 2026
785fb92
fix(proxy): hydrate forwarding keyword settings before translation
i1hwan Apr 11, 2026
1ddaad4
ci(docker): publish ApexRoute amd64 image
i1hwan Apr 11, 2026
d07ab62
fix(proxy): preserve forwarding keyword runtime config
i1hwan Apr 11, 2026
9425d26
fix(proxy): retain forwarded tool name mappings
i1hwan Apr 11, 2026
f17df6a
fix(settings): reject blank forwarding keyword matches
i1hwan Apr 11, 2026
b45d2a7
fix(settings): clear forwarding keyword status timer
i1hwan Apr 11, 2026
22667e6
fix(proxy): preserve Claude-native tool choice names
i1hwan Apr 11, 2026
b513622
Merge pull request #1 from i1hwan/fix/claude-oauth-lexical-compat
i1hwan Apr 11, 2026
76f8982
fix(docker): tighten runtime image packaging
i1hwan Apr 11, 2026
a3e28d1
fix(docker): skip Docker Hub description updates
i1hwan Apr 11, 2026
fc8e0e0
Potential fix for pull request finding
i1hwan Apr 11, 2026
4ebe6b9
Merge pull request #2 from i1hwan/fix/docker-image-hygiene
i1hwan Apr 11, 2026
c8d05e6
fix(proxy): apply lexical rewrite to native Claude passthrough
i1hwan Apr 11, 2026
6524758
fix(settings): clarify forwarding keyword status
i1hwan Apr 11, 2026
36fe4a7
fix(proxy): cover remaining Claude passthrough review gaps
i1hwan Apr 11, 2026
31ad7be
Merge pull request #3 from i1hwan/fix/native-claude-forwarding
i1hwan Apr 11, 2026
2d9f5f6
fix(settings): preserve default forwarding keyword rewrites
i1hwan Apr 11, 2026
98fc781
Merge pull request #4 from i1hwan/fix/native-claude-forwarding
i1hwan Apr 11, 2026
2152d68
fix(proxy): rewrite Claude Code compatible requests
i1hwan Apr 11, 2026
2b377e6
Merge pull request #5 from i1hwan/fix/native-claude-forwarding
i1hwan Apr 11, 2026
ea3a9c6
fix(proxy): cover remaining Claude lexical rewrite bypasses
i1hwan Apr 11, 2026
ad750a4
Merge pull request #6 from i1hwan/fix/native-claude-forwarding
i1hwan Apr 11, 2026
617dc3b
fix(proxy): preserve Claude token ceilings
i1hwan Apr 11, 2026
6b29677
fix(settings): add adaptive Claude thinking mode
i1hwan Apr 11, 2026
f602e1a
fix(settings): wire adaptive thinking configuration
i1hwan Apr 11, 2026
a83f0dc
fix(proxy): pass adaptive thinking through Claude requests
i1hwan Apr 11, 2026
81059cb
fix(settings): clarify adaptive thinking controls
i1hwan Apr 11, 2026
7d320f8
test(proxy): align Claude compatible effort expectations
i1hwan Apr 11, 2026
a596c06
fix(settings): preserve explicit Claude thinking config
i1hwan Apr 11, 2026
ef307cb
fix(proxy): preserve Claude compatible xhigh effort
i1hwan Apr 11, 2026
4434335
Merge pull request #7 from i1hwan/fix/native-claude-forwarding
i1hwan Apr 11, 2026
d52a4dc
fix(settings): auto-promote Claude effort without mode gate
i1hwan Apr 11, 2026
8f6e295
fix(settings): promote Claude thinkingLevel-only requests
i1hwan Apr 11, 2026
59aeb1c
fix(healthcheck): unwrap connection proxy before token refresh
i1hwan Apr 11, 2026
6047c25
fix(auth): harden Claude OAuth token refresh format
i1hwan Apr 11, 2026
8674d31
Merge pull request #8 from i1hwan/fix/native-claude-forwarding
i1hwan Apr 11, 2026
f2d6955
fix(auth): match Claude refresh token contract
i1hwan Apr 11, 2026
1ef6f5e
Merge pull request #9 from i1hwan/fix/claude-refresh-contract
i1hwan Apr 11, 2026
169bbb1
fix(oauth): restore Claude callback defaults
i1hwan Apr 11, 2026
75f72b0
fix(auth): include Claude scope in refresh contract
i1hwan Apr 11, 2026
77892f8
fix(oauth): align Claude callback with upstream
i1hwan Apr 11, 2026
5c2b46f
Merge pull request #10 from i1hwan/fix/claude-refresh-contract
i1hwan Apr 11, 2026
e12e142
fix(auth): restore upstream Claude refresh contract
i1hwan Apr 11, 2026
b7e3fca
Merge pull request #11 from i1hwan/fix/claude-refresh-contract
i1hwan Apr 11, 2026
43cd5ed
fix(auth): trim Claude refresh request shape
i1hwan Apr 11, 2026
aeffa36
fix(auth): log Claude refresh request details
i1hwan Apr 11, 2026
8ab7f66
Merge pull request #12 from i1hwan/fix/claude-refresh-contract
i1hwan Apr 12, 2026
4cc9622
fix(auth): restore oauth client id bootstrap defaults
i1hwan Apr 12, 2026
f243ae1
Merge pull request #13 from i1hwan/fix/claude-refresh-contract
i1hwan Apr 12, 2026
4d37b6e
fix(usage): translate Claude cache tokens to OpenAI prompt_tokens_det…
i1hwan Apr 12, 2026
597d38e
fix(usage): delete flat cache/reasoning keys after promoting to neste…
i1hwan Apr 12, 2026
b061aa7
fix(usage): merge streaming usage state instead of overwriting
i1hwan Apr 12, 2026
af72927
docs: fix inaccurate comment about message_delta usage fields
i1hwan Apr 14, 2026
6e6060a
Merge pull request #14 from i1hwan/fix/claude-refresh-contract
i1hwan Apr 14, 2026
aadee43
fix(thinking): apply thinking budget on cache_control passthrough path
i1hwan Apr 16, 2026
cdae57b
fix(thinking): preserve thinking config on Claude multi-turn requests
i1hwan Apr 16, 2026
a0d04f7
test: cover thinking budget application on Claude passthrough cache_c…
i1hwan Apr 16, 2026
3ce704a
Merge pull request #15 from i1hwan/fix/reasoning-effort-passthrough
i1hwan Apr 17, 2026
cc40141
feat(models): add Claude Opus 4.7 spec with adaptive-only thinking co…
i1hwan Apr 18, 2026
5a2946b
fix(thinking): enforce adaptive-only thinking and sampling constraint…
i1hwan Apr 18, 2026
83dff9c
test: add Opus 4.7 compatibility tests (26 cases)
i1hwan Apr 18, 2026
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
17 changes: 16 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,26 @@ blob-report
# Documentation (not needed in container)
docs
*.md
!README.md

# Electron (separate build)
electron

# Container and CI metadata
Dockerfile
docker-compose.yml
docker-compose.prod.yml
fly.toml
sonar-project.properties

# Local engineering metadata
AGENTS.md
playwright.config.ts
vitest.config.ts
vitest.mcp.config.ts
eslint.config.mjs
prettier.config.mjs
llm.txt

# VS Code extension (separate project)
vscode-extension

Expand Down
17 changes: 4 additions & 13 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
name: Build and Push Docker (multi-arch)
runs-on: ubuntu-latest
env:
IMAGE_NAME: diegosouzapw/omniroute
IMAGE_NAME: i1hwan/apexroute
steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down Expand Up @@ -65,13 +65,13 @@ jobs:
with:
context: .
target: runner-base
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.IMAGE_NAME }}:latest
ghcr.io/diegosouzapw/omniroute:${{ steps.version.outputs.version }}
ghcr.io/diegosouzapw/omniroute:latest
ghcr.io/i1hwan/apexroute:${{ steps.version.outputs.version }}
ghcr.io/i1hwan/apexroute:latest
Comment on lines 22 to +74
cache-from: type=gha
cache-to: type=gha,mode=max
no-cache: false
Expand All @@ -81,12 +81,3 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"

- name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: diegosouzapw/omniroute
short-description: "OmniRoute — Unified AI proxy. Route any LLM through one endpoint."
readme-filepath: ./README.md
33 changes: 32 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,37 @@ COPY scripts/native-binary-compat.mjs ./scripts/native-binary-compat.mjs
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi

COPY . ./
RUN mkdir -p /app/data && npm run build -- --webpack
RUN mkdir -p /app/data \
&& npm run build -- --webpack \
&& rm -f /app/.next/standalone/.env \
&& rm -rf \
/app/.next/standalone/.git \
/app/.next/standalone/coverage \
/app/.next/standalone/docs \
/app/.next/standalone/electron \
/app/.next/standalone/images \
/app/.next/standalone/logs \
/app/.next/standalone/tests \
&& rm -f \
/app/.next/standalone/AGENTS.md \
/app/.next/standalone/CHANGELOG.md \
/app/.next/standalone/CONTRIBUTING.md \
/app/.next/standalone/Dockerfile \
/app/.next/standalone/README.md \
/app/.next/standalone/SECURITY.md \
/app/.next/standalone/docker-compose.yml \
/app/.next/standalone/docker-compose.prod.yml \
/app/.next/standalone/eslint.config.mjs \
/app/.next/standalone/fly.toml \
/app/.next/standalone/llm.txt \
/app/.next/standalone/playwright.config.ts \
/app/.next/standalone/prettier.config.mjs \
/app/.next/standalone/sonar-project.properties \
/app/.next/standalone/tsconfig.json \
/app/.next/standalone/tsconfig.typecheck-core.json \
/app/.next/standalone/tsconfig.typecheck-noimplicit-core.json \
/app/.next/standalone/vitest.config.ts \
/app/.next/standalone/vitest.mcp.config.ts

FROM node:22.22.2-trixie-slim AS runner-base
WORKDIR /app
Expand Down Expand Up @@ -48,6 +78,7 @@ COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs
COPY --from=builder /app/scripts/bootstrap-env.mjs ./bootstrap-env.mjs
COPY --from=builder /app/scripts/healthcheck.mjs ./healthcheck.mjs
COPY --from=builder /app/.env.example ./.env.example

EXPOSE 20128

Expand Down
207 changes: 207 additions & 0 deletions open-sse/config/forwardingKeywordRules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
type ForwardingKeywordLane = "claude-oauth-prefixed";

type ForwardingKeywordRule = {
match: string;
replace: string;
};

type ForwardingTagRule = {
open: string;
openReplacement: string;
close: string;
closeReplacement: string;
};

type ForwardingKeywordRules = {
toolNames: ForwardingKeywordRule[];
text: ForwardingKeywordRule[];
tags: ForwardingTagRule[];
};

type ForwardingKeywordConfig = Record<ForwardingKeywordLane, ForwardingKeywordRules>;

export const DEFAULT_FORWARDING_KEYWORD_CONFIG: ForwardingKeywordConfig = {
"claude-oauth-prefixed": {
toolNames: [
{ match: "background_output", replace: "background_result" },
{ match: "background_cancel", replace: "background_stop" },
],
text: [
{ match: "background_output", replace: "background_result" },
{ match: "background_cancel", replace: "background_stop" },
],
tags: [
{
open: "<directories>",
openReplacement: "directories:\n",
close: "</directories>",
closeReplacement: "",
},
],
},
};

let forwardingKeywordConfig: ForwardingKeywordConfig = cloneForwardingKeywordConfig(
DEFAULT_FORWARDING_KEYWORD_CONFIG
);

function cloneForwardingKeywordConfig(config: ForwardingKeywordConfig): ForwardingKeywordConfig {
return JSON.parse(JSON.stringify(config)) as ForwardingKeywordConfig;
}

function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalizedValue = value.trim();
return normalizedValue ? normalizedValue : null;
}

function normalizeKeywordRule(rule: unknown): ForwardingKeywordRule | null {
if (!rule || typeof rule !== "object") return null;
const candidate = rule as Record<string, unknown>;
const match = normalizeNonEmptyString(candidate.match);
if (!match || typeof candidate.replace !== "string") return null;
return { match, replace: candidate.replace };
}

function normalizeTagRule(rule: unknown): ForwardingTagRule | null {
if (!rule || typeof rule !== "object") return null;
const candidate = rule as Record<string, unknown>;
const open = normalizeNonEmptyString(candidate.open);
const close = normalizeNonEmptyString(candidate.close);
if (
!open ||
typeof candidate.openReplacement !== "string" ||
!close ||
typeof candidate.closeReplacement !== "string"
) {
return null;
}

return {
open,
openReplacement: candidate.openReplacement,
close,
closeReplacement: candidate.closeReplacement,
};
}

function mergeKeywordRules(
defaults: ForwardingKeywordRule[],
overrides: ForwardingKeywordRule[]
): ForwardingKeywordRule[] {
if (overrides.length === 0) return defaults;

const merged = new Map(defaults.map((rule) => [rule.match, rule]));
for (const override of overrides) {
merged.set(override.match, override);
}

return Array.from(merged.values());
}

function mergeTagRules(
defaults: ForwardingTagRule[],
overrides: ForwardingTagRule[]
): ForwardingTagRule[] {
if (overrides.length === 0) return defaults;

const merged = new Map(defaults.map((rule) => [`${rule.open}::${rule.close}`, rule]));
for (const override of overrides) {
merged.set(`${override.open}::${override.close}`, override);
}

return Array.from(merged.values());
}

export function normalizeForwardingKeywordConfig(value: unknown): ForwardingKeywordConfig {
const normalized = cloneForwardingKeywordConfig(DEFAULT_FORWARDING_KEYWORD_CONFIG);
if (!value || typeof value !== "object") return normalized;

const rawConfig = value as Record<string, unknown>;
for (const lane of Object.keys(DEFAULT_FORWARDING_KEYWORD_CONFIG) as ForwardingKeywordLane[]) {
const rawLane = rawConfig[lane];
if (!rawLane || typeof rawLane !== "object") continue;

const rawLaneConfig = rawLane as Record<string, unknown>;
const toolNameOverrides = Array.isArray(rawLaneConfig.toolNames)
? rawLaneConfig.toolNames.map(normalizeKeywordRule).filter(Boolean)
: [];
const textOverrides = Array.isArray(rawLaneConfig.text)
? rawLaneConfig.text.map(normalizeKeywordRule).filter(Boolean)
: [];
const tagOverrides = Array.isArray(rawLaneConfig.tags)
? rawLaneConfig.tags.map(normalizeTagRule).filter(Boolean)
: [];

normalized[lane] = {
toolNames: mergeKeywordRules(normalized[lane].toolNames, toolNameOverrides),
text: mergeKeywordRules(normalized[lane].text, textOverrides),
tags: mergeTagRules(normalized[lane].tags, tagOverrides),
};
}

return normalized;
}

export function setForwardingKeywordConfig(config: unknown) {
forwardingKeywordConfig = normalizeForwardingKeywordConfig(config);
}

export function applyForwardingKeywordSettings(settings: Record<string, unknown>) {
if (Object.prototype.hasOwnProperty.call(settings, "forwardingKeywordRules")) {
setForwardingKeywordConfig(settings.forwardingKeywordRules);
return;
}

setForwardingKeywordConfig(getDefaultForwardingKeywordConfig());
}

export function getForwardingKeywordConfig(): ForwardingKeywordConfig {
return cloneForwardingKeywordConfig(forwardingKeywordConfig);
}

export function getDefaultForwardingKeywordConfig(): ForwardingKeywordConfig {
return cloneForwardingKeywordConfig(DEFAULT_FORWARDING_KEYWORD_CONFIG);
}

export function rewriteForwardedTextForLane(lane: ForwardingKeywordLane, text: string): string {
if (!text) return text;

let rewrittenText = text;
const rules = forwardingKeywordConfig[lane];

for (const rule of rules.text) {
rewrittenText = rewrittenText.replaceAll(rule.match, rule.replace);
}
Comment on lines +173 to +175

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Applying multiple replaceAll calls in a loop can lead to unexpected results if a replacement string contains a keyword that matches a subsequent rule (double-rewriting). While the current default rules seem safe, this pattern is fragile for general-purpose lexical rewriting. Consider using a single-pass regex-based replacement or ensuring that rules are mutually exclusive.


for (const tagRule of rules.tags) {
rewrittenText = rewrittenText
.replaceAll(tagRule.open, tagRule.openReplacement)
.replaceAll(tagRule.close, tagRule.closeReplacement);
}

return rewrittenText;
}

export function rewriteForwardedToolNameForLane(
lane: ForwardingKeywordLane,
toolName: string
): string {
const rules = forwardingKeywordConfig[lane];
const matchingRule = rules.toolNames.find((rule) => rule.match === toolName);
return matchingRule?.replace ?? toolName;
}

export function getForwardingKeywordRulesForLane(
lane: ForwardingKeywordLane
): ForwardingKeywordRules {
return cloneForwardingKeywordConfig(forwardingKeywordConfig)[lane];
}

export type {
ForwardingKeywordConfig,
ForwardingKeywordLane,
ForwardingKeywordRule,
ForwardingKeywordRules,
ForwardingTagRule,
};
1 change: 1 addition & 0 deletions open-sse/config/providerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
tokenUrl: "https://console.anthropic.com/v1/oauth/token",
},
models: [
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet" },
{ id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" },
Expand Down
22 changes: 20 additions & 2 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getCorsOrigin } from "../utils/cors.ts";
import { detectFormatFromEndpoint, getTargetFormat } from "../services/provider.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { applyThinkingBudget } from "../services/thinkingBudget.ts";
import { FORMATS } from "../translator/formats.ts";
import {
createSSETransformStreamWithLogger,
Expand Down Expand Up @@ -69,6 +70,7 @@ import {
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
import { applyClaudeOAuthLexicalRewrite } from "../translator/helpers/claudeHelper.ts";
import {
parseSSEToClaudeResponse,
parseSSEToOpenAIResponse,
Expand Down Expand Up @@ -1017,8 +1019,17 @@ export async function handleChatCore({
// as-is without prior normalization. The OpenAI round-trip would strip
// cache_control markers; even prepareClaudeRequest can alter structure.
// Claude Code sends well-formed Messages API payloads — trust it.
translatedBody = { ...body };
// Still apply thinking budget to convert reasoning_effort → Claude thinking
// (applyThinkingBudget only touches thinking/reasoning fields, preserves cache_control).
translatedBody = applyThinkingBudget({ ...body });
translatedBody._disableToolPrefix = true;
if (provider === "claude") {
const lexicalRewrite = applyClaudeOAuthLexicalRewrite(translatedBody);
translatedBody = lexicalRewrite.body;
if (lexicalRewrite.toolNameMap) {
translatedBody._toolNameMap = lexicalRewrite.toolNameMap;
}
}

log?.debug?.("FORMAT", "claude passthrough with cache_control preservation");
} else if (isClaudePassthrough) {
Expand Down Expand Up @@ -1057,6 +1068,13 @@ export async function handleChatCore({
reqLogger,
{ normalizeToolCallId, preserveDeveloperRole, preserveCacheControl }
);
if (provider === "claude") {
const lexicalRewrite = applyClaudeOAuthLexicalRewrite(translatedBody);
translatedBody = lexicalRewrite.body;
if (lexicalRewrite.toolNameMap) {
translatedBody._toolNameMap = lexicalRewrite.toolNameMap;
}
}
log?.debug?.("FORMAT", "claude->openai->claude normalized passthrough");
} else {
translatedBody = { ...body };
Expand All @@ -1066,7 +1084,7 @@ export async function handleChatCore({
// conflicts with Claude OAuth tools, but in the passthrough path the tools
// are already in Claude format. Applying the prefix turns "Bash" into
// "proxy_Bash", which Claude rejects ("No such tool available: proxy_Bash").
if (targetFormat === FORMATS.CLAUDE) {
if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) {
translatedBody._disableToolPrefix = true;
}

Expand Down
Loading