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
54 changes: 54 additions & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,60 @@ When `quota.skip_exhausted: true`, the account pool skips accounts whose cached

The skip condition is currently `rate_limit.limit_reached === true`, `secondary_rate_limit.limit_reached === true`, or `code_review_rate_limit.limit_reached === true` in cached quota. If `used_percent` is merely near 100, for example 99%, but upstream has not set `limit_reached`, the proxy may still use that account. Once upstream returns 429, the account is marked `rate_limited`, enters backoff, and the request is retried with another available account. Secondary and code-review windows are removed from cache after their own `reset_at` passes, so an account is not skipped forever on stale quota data.

### Account routing by service tier

Use `auth.service_tier_routing` to reserve accounts for a service tier. The keys
match the effective `service_tier` exactly; the proxy does not translate marketing
names into tier IDs or infer entitlements. Confirm the upstream tier and account
eligibility before configuring a rule.

```yaml
model:
service_tier_overrides:
gpt-6.1-sol: ultrafast
auth:
service_tier_routing:
ultrafast:
account_ids: ["reserved-account-entry-id"]
fallback_to_default: true
default:
exclude_account_ids: ["reserved-account-entry-id"]
standard:
exclude_account_ids: ["reserved-account-entry-id"]
```

This example forces all OAuth GPT-6.1-Sol inference requests to `ultrafast`,
even when a client requests another tier. It reserves one account for ultrafast and
keeps requests using `default` or `standard` on the other accounts. The override
uses the resolved model ID and does not change the model itself. Without a model
override, client tier selection is preserved. Normal Astra requests should omit
the tier unless the upstream explicitly supports the requested tier value. `account_ids` and `exclude_account_ids` use the proxy account entry
`id` shown by `/auth/accounts`, not the upstream ChatGPT account ID or email.

Alternatively, use `plan_types: ["pro"]` when the reported plan type uniquely
identifies eligible accounts. Account plan metadata may not distinguish Pro
subscription variants; use explicit entry IDs in that case. If multiple fields
are supplied, all restrictions must match, and exclusions take precedence.
Unknown plan types cannot satisfy a `plan_types` restriction.

Rules are applied after model eligibility, quota and concurrency checks, before
plan priority, session affinity and rotation. Account retries retain the tier.
With `fallback_to_default: true`, unavailable fast-tier accounts trigger another
selection using the `default` rule and send `service_tier: default` upstream.
This covers missing, disabled, busy, exhausted, and previously tried accounts.
The model and reasoning effort are unchanged. Fallback is one-way and optional.
Without this option, if all matching accounts are unavailable, the request fails instead of selecting
an excluded account or using the API-key fallback. Compact requests also apply
these account restrictions, without adding a service tier to the compact payload.
Explicitly routed third-party API providers do not use the OAuth account pool and
are outside these rules.

When no request tier is specified, `model.default_service_tier` is used, falling
back to the key `default`. Unconfigured tiers retain existing selection behavior.
Rules are empty by default. Empty lists and empty rules are rejected to catch
configuration errors. This is account selection policy, not an upstream entitlement
check or a guarantee of a particular response speed.

### Ollama Bridge Configuration

```yaml
Expand Down
4 changes: 4 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ model:
image_host_model: gpt-5.5
default_reasoning_effort: null
default_service_tier: null
# Optional forced service tier per OAuth model, overriding client preferences.
service_tier_overrides: {}
default_tools: []
aliases: {}
custom_models: []
Expand All @@ -37,6 +39,8 @@ auth:
refresh_enabled: true
refresh_margin_seconds: 300
rotation_strategy: least_used
# Hard account restrictions keyed by effective service_tier; see README_EN.md.
service_tier_routing: {}
rate_limit_backoff_seconds: 60
oauth_client_id: app_EMoamEEZ73f0CkXaXp7hrann
oauth_auth_endpoint: https://auth.openai.com/oauth/authorize
Expand Down
20 changes: 19 additions & 1 deletion src/auth/account-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Uses AccountRegistry for entry access (no circular dep — one-way reference).
*/

import { getServiceTierAccountRule } from "./service-tier-routing.js";
import { getConfig } from "../config.js";
import { getModelPlanTypes, isPlanFetched } from "../models/model-store.js";
import { hasReachedCachedQuota } from "./quota-skip.js";
Expand Down Expand Up @@ -72,7 +73,7 @@ export class AccountLifecycle {
}
}

acquire(options?: { model?: string; excludeIds?: string[]; preferredEntryId?: string }): AcquiredAccount | null {
acquire(options?: { model?: string; serviceTier?: string | null; excludeIds?: string[]; preferredEntryId?: string }): AcquiredAccount | null {
const nowMs = Date.now();
const now = new Date(nowMs);

Expand Down Expand Up @@ -117,6 +118,23 @@ export class AccountLifecycle {
}
}

const rule = getServiceTierAccountRule(options?.serviceTier);
if (rule) {
candidates = candidates.filter((account) =>
(!rule.plan_types || (account.planType != null && rule.plan_types.includes(account.planType))) &&
(!rule.account_ids || rule.account_ids.includes(account.id)) &&
!rule.exclude_account_ids?.includes(account.id),
);
if (candidates.length === 0) {
const tier = options?.serviceTier ?? config.model.default_service_tier ?? "default";
if (rule.fallback_to_default && tier !== "default") {
const fallback = this.acquire({ ...options, serviceTier: "default" });
return fallback ? { ...fallback, serviceTier: "default" } : null;
}
return null;
}
}

// Tier-based filtering: when configured, restrict to the highest available tier
const tierPriority = config.auth.tier_priority;
if (tierPriority && tierPriority.length > 0) {
Expand Down
2 changes: 1 addition & 1 deletion src/auth/account-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export class AccountPool {

// ── Lifecycle (acquire/release) ───────────────────────────────────

acquire(options?: { model?: string; excludeIds?: string[]; preferredEntryId?: string }): AcquiredAccount | null {
acquire(options?: { model?: string; serviceTier?: string | null; excludeIds?: string[]; preferredEntryId?: string }): AcquiredAccount | null {
return this.lifecycle.acquire(options);
}

Expand Down
16 changes: 16 additions & 0 deletions src/auth/service-tier-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getConfig } from "../config.js";

/** Missing tiers use the configured default, then the upstream standard tier. */
export function getServiceTierAccountRule(serviceTier?: string | null) {
const config = getConfig();
if (!config.auth.service_tier_routing) return undefined;
const tier = serviceTier ?? config.model.default_service_tier ?? "default";
const rules = config.auth.service_tier_routing;
return Object.hasOwn(rules, tier) ? rules[tier] : undefined;
}

/** Exact model IDs; only explicit operator overrides supersede the client. */
export function getModelServiceTierOverride(model: string): string | undefined {
const overrides = getConfig().model?.service_tier_overrides;
return overrides && Object.hasOwn(overrides, model) ? overrides[model] : undefined;
}
2 changes: 2 additions & 0 deletions src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export interface CodexQuota {

/** Returned by acquire() */
export interface AcquiredAccount {
/** Set when account selection downgraded the requested service tier. */
serviceTier?: "default";
entryId: string;
token: string;
accountId: string | null;
Expand Down
11 changes: 11 additions & 0 deletions src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export const ConfigSchema = z.object({
.default("gpt-5.5"),
default_reasoning_effort: z.string().nullable().default(null),
default_service_tier: z.string().nullable().default(null),
/** Forced service tiers for OAuth models, overriding client tier preferences. */
service_tier_overrides: z.record(z.string().trim().min(1), z.string().trim().min(1)).default({}),
default_tools: z.array(z.string().trim().min(1)).default([]),
aliases: z.record(z.string(), z.string()).default({}),
custom_models: z.array(CustomModelSchema).default([]),
Expand Down Expand Up @@ -122,6 +124,15 @@ export const ConfigSchema = z.object({
rotation_strategy: z.enum(ROTATION_STRATEGIES).default("least_used"),
/** Preferred plan-type ordering for account selection (e.g. ["plus","team","free"]). */
tier_priority: z.array(z.string()).nullable().default(null),
/** Hard account restrictions keyed by the effective service tier. */
service_tier_routing: z.record(z.string().trim().min(1), z.object({
fallback_to_default: z.boolean().optional(),
plan_types: z.array(z.string().trim().min(1)).min(1).optional(),
account_ids: z.array(z.string().trim().min(1)).min(1).optional(),
exclude_account_ids: z.array(z.string().trim().min(1)).min(1).optional(),
}).strict().refine((rule) => rule.plan_types || rule.account_ids || rule.exclude_account_ids, {
message: "A service-tier rule must contain at least one account restriction",
})).default({}),
rate_limit_backoff_seconds: z.number().min(1).default(60),
oauth_client_id: z.string().default("app_EMoamEEZ73f0CkXaXp7hrann"),
oauth_auth_endpoint: z.string().default("https://auth.openai.com/oauth/authorize"),
Expand Down
7 changes: 5 additions & 2 deletions src/routes/responses-compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Responses API compact handler — non-streaming JSON proxy for /v1/responses/compact.
*/

import { getModelServiceTierOverride } from "../auth/service-tier-routing.js";
import type { Context } from "hono";
import type { StatusCode } from "hono/utils/http-status";
import type { AccountPool } from "../auth/account-pool.js";
Expand Down Expand Up @@ -94,6 +95,8 @@ export async function handleCompact(

const parsed = parseModelName(rawModel);
const modelId = resolveModelId(parsed.modelId);
const serviceTier = getModelServiceTierOverride(modelId) ??
(typeof body.service_tier === "string" ? body.service_tier : parsed.serviceTier);

const compactRequest: CodexCompactRequest = {
model: modelId,
Expand Down Expand Up @@ -158,7 +161,7 @@ export async function handleCompact(
const triedEntryIds: string[] = [];
const released = new Set<string>();

const acquired = acquireAccount(accountPool, modelId, undefined, TAG);
const acquired = acquireAccount(accountPool, modelId, undefined, TAG, undefined, serviceTier);
if (!acquired) {
c.status(503);
return c.json(formatResponsesError(503, "No available accounts. All accounts are expired or rate-limited."));
Expand Down Expand Up @@ -205,7 +208,7 @@ export async function handleCompact(
releaseAccount(accountPool, entryId, annotateUsageCost(modelId, compactImageFailedUsage), released);
}

const retry = acquireAccount(accountPool, modelId, triedEntryIds, TAG);
const retry = acquireAccount(accountPool, modelId, triedEntryIds, TAG, undefined, serviceTier);
if (!retry) {
const status = decision.status as StatusCode;
c.status(status);
Expand Down
3 changes: 2 additions & 1 deletion src/routes/shared/account-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ export function acquireAccount(
excludeIds?: string[],
tag?: string,
preferredEntryId?: string,
serviceTier?: string | null,
): AcquiredAccount | null {
const acquired = pool.acquire({ model, excludeIds, preferredEntryId });
const acquired = pool.acquire({ model, excludeIds, preferredEntryId, ...(serviceTier != null ? { serviceTier } : {}) });
if (!acquired && tag) {
console.warn(`[${tag}] No available account for model "${model}"`);
}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/shared/non-streaming-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export async function retryNonStreamingEmptyResponse(
releaseAccount(accountPool, currentEntryId, annotateUsageCost(req.model, annotateImageGenOutcome(collectErr.usage, req.expectsImageGen)), released);
restoreImplicitResumeRequest?.();

const acquired = acquireAccount(accountPool, req.codexRequest.model, undefined, tag);
const acquired = acquireAccount(accountPool, req.codexRequest.model, undefined, tag, undefined, req.codexRequest.service_tier);
if (!acquired) {
return {
action: "respond",
Expand All @@ -300,6 +300,7 @@ export async function retryNonStreamingEmptyResponse(
};
}

if (acquired.serviceTier) req.codexRequest.service_tier = acquired.serviceTier;
const nextApi = buildCodexApi(
acquired.token,
acquired.accountId,
Expand Down
5 changes: 5 additions & 0 deletions src/routes/shared/proxy-error-retry-transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ export type ProxyErrorRetryTransitionResult =
entryId: string;
api: CodexApi;
prevSlotMs: number | null;
serviceTier?: "default";
modelRetried: boolean;
};

export interface ApplyProxyErrorRetryTransitionOptions {
accountPool: AccountPool;
entryId: string;
model: string;
serviceTier?: string | null;
triedEntryIds: string[];
tag: string;
decision: ErrorAction;
Expand All @@ -49,6 +51,7 @@ export function applyProxyErrorRetryTransition(
accountPool,
entryId,
model,
serviceTier,
triedEntryIds,
tag,
decision,
Expand Down Expand Up @@ -79,6 +82,7 @@ export function applyProxyErrorRetryTransition(
const fallbackRetry = prepareProxyFallbackAccountRetry({
accountPool,
model,
serviceTier,
triedEntryIds,
tag,
decision,
Expand All @@ -102,6 +106,7 @@ export function applyProxyErrorRetryTransition(
entryId: fallbackRetry.entryId,
api: fallbackRetry.api,
prevSlotMs: fallbackRetry.prevSlotMs,
...(fallbackRetry.serviceTier ? { serviceTier: fallbackRetry.serviceTier } : {}),
modelRetried: nextModelRetried,
};
}
6 changes: 5 additions & 1 deletion src/routes/shared/proxy-fallback-account-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ export type ProxyFallbackAccountRetryResult =
entryId: string;
api: CodexApi;
prevSlotMs: number | null;
serviceTier?: "default";
};

export interface PrepareProxyFallbackAccountRetryOptions {
accountPool: AccountPool;
model: string;
serviceTier?: string | null;
triedEntryIds: string[];
tag: string;
decision: RetryDecision;
Expand All @@ -41,6 +43,7 @@ export function prepareProxyFallbackAccountRetry(
const {
accountPool,
model,
serviceTier,
triedEntryIds,
tag,
decision,
Expand All @@ -62,7 +65,7 @@ export function prepareProxyFallbackAccountRetry(
return fallbackPlan;
}

const retry = acquireAccount(accountPool, model, excludeEntryIds, tag);
const retry = acquireAccount(accountPool, model, excludeEntryIds, tag, undefined, serviceTier);
if (!retry) {
return {
action: "respond",
Expand All @@ -87,5 +90,6 @@ export function prepareProxyFallbackAccountRetry(
entryId: retry.entryId,
api,
prevSlotMs: retry.prevSlotMs,
...(retry.serviceTier ? { serviceTier: retry.serviceTier } : {}),
};
}
Loading
Loading