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
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,20 @@ vault reachability, provider ownership and the accounts available for routing.
Use `/claustrum refusals` for rows the plugin cannot serve. See
[provider metadata](providers.md#the-operators-vault-rows) if a row is refused
despite a successful grant.

## Thalamus routing

When Thalamus supplies its routing accessor and the plugin's next row is an
API key, routing chooses that request's vault API key, including for titles,
compaction and retries, from the enabled
accounts for that provider. The plugin never substitutes its own choice. A
routing refusal stops the request with "Thalamus routing declined this request,
so it was not sent." No answer stops it with "Thalamus routing did not answer,
so this API key request was not sent." Slow routing (over 1.5 seconds) counts
as no answer and stops the API-key request too. Documented empty-balance
refusals are reported back to routing; user-set spending caps are not.
Outside Thalamus, without its accessor, account selection is unchanged.

Subscription (OAuth) rows are not routed yet. If the plugin's usual next row
is an OAuth login, it serves that login as before without asking routing; only
an API-key attempt asks routing to choose among eligible API keys.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
},
"dependencies": {
"@cortexkit/claustrum-client": "^0.18.0",
"@cortexkit/common-auth": "^0.44.0",
"@cortexkit/common-auth": "^0.47.0",
"@cortexkit/private-files": "^0.2.2",
"@cortexkit/subc-client": "^0.24.0"
}
Expand Down
157 changes: 101 additions & 56 deletions packages/core/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
redactError,
redactResponse,
} from './placement.js'
import { reportRoutedBalance, selectRoutedKey } from './routing.js'
import { placeReceipt, type ServingOptions } from './serving.js'

export const maxBodyBytes = 64 * 1024 * 1024
Expand All @@ -33,6 +34,7 @@ export interface FetchOptions extends ServingOptions {
// case rules and config precedence. The check is repeated on every request.
effectiveCredential?: () => boolean | Promise<boolean>
billing?: (receipt: ClaustrumScopedAttempt) => BillingAttempt | undefined
routing?: { sessionId: string | undefined; provider: string; modelId: string }
}

async function bufferBody(request: Request): Promise<Uint8Array | undefined> {
Expand Down Expand Up @@ -101,6 +103,7 @@ export function createFetch(options: FetchOptions): CoreFetch {
cache: request.cache,
}
const tried = new Set<string>()
let routedAttempt = false
const rejected = new Set<string>()
const secrets: string[] = []
let redirectBilling:
Expand Down Expand Up @@ -191,12 +194,10 @@ export function createFetch(options: FetchOptions): CoreFetch {
try {
for (;;) {
signal.throwIfAborted()
if (routedAttempt && pending && !pending.parsed)
return await redactResponse(pending.response, secrets)
const rows = await candidates()
if (!pending && !rows.some((row) => row.admitted))
throw new ClaustrumAuthError('no-vault-account')
// A failed provider response stays readable until a replacement send
// is committed. Reloads must not turn it into a fresh-request refusal.
const initial = pending
const ownDecision = pending
? decide(
'after-limit',
rows,
Expand All @@ -205,6 +206,39 @@ export function createFetch(options: FetchOptions): CoreFetch {
pending.parsed,
)
: decide('pre-send', rows)
if (ownDecision.action === 'pass' && pending)
return await redactResponse(pending.response, secrets)
const nextRoute =
ownDecision.action === 'reroute'
? ownDecision.account.accountId
: rows.find((row) => row.admitted && row.placementEligible)?.account
.accountId
const routed = await selectRoutedKey(
options.consumer,
options.routing?.provider ?? options.entry.service,
options.routing?.modelId ?? '',
options.routing?.sessionId,
new Set([...tried, ...rejected]),
nextRoute,
)
routedAttempt = routed !== undefined
const routedId = routed?.routeId
if (!pending && !rows.some((row) => row.admitted))
throw new ClaustrumAuthError('no-vault-account')
// Keep a failed provider response readable until a replacement send
// is committed, so a vault roster reload cannot turn a retry into a
// fresh-request refusal instead of returning the provider's response.
const initial = routed
? {
action: 'reroute' as const,
account: rows.find((row) => row.account.accountId === routedId)
?.account ?? {
namespace: 'claustrum-auth:routing',
accountId: routed.routeId,
incarnation: 'routing',
},
}
: ownDecision
if (initial.action === 'pass' && pending)
return await redactResponse(pending.response, secrets)
if (initial.action === 'refuse') {
Expand All @@ -214,11 +248,11 @@ export function createFetch(options: FetchOptions): CoreFetch {
if (initial.action !== 'reroute')
throw new Error('claustrum-auth: unsupported limit action')
const route = initial.account.accountId
if (limits && !(await limits.beforeDispatch(route))) {
if (!routed && limits && !(await limits.beforeDispatch(route))) {
rejected.add(route)
continue
}
if (limits) {
if (!routed && limits) {
if (
!(await candidates()).find((row) => row.account.accountId === route)
?.ready
Expand All @@ -238,54 +272,54 @@ export function createFetch(options: FetchOptions): CoreFetch {
for (;;) {
signal.throwIfAborted()
checkOrigin(target, options.entry)
response = await options.consumer.send(
route,
async (receipt, dispatchSignal) => {
secrets.push(receipt.accessToken)
const draft = placeReceipt(
target,
headers,
options.entry,
receipt,
)
const dispatchHeaders = new Headers(draft.headers)
if (!bytes) {
dispatchHeaders.delete('content-length')
if (method !== template.method)
dispatchHeaders.delete('content-type')
}
const outbound = new Request(draft.url, {
method,
headers: dispatchHeaders,
body: bytes ? new Uint8Array(bytes) : undefined,
signal: dispatchSignal ?? signal,
redirect: 'manual',
credentials: template.credentials,
cache: template.cache,
})
let billing: BillingAttempt | undefined
try {
const reply = await options.fetch(outbound)
if (
options.billing &&
[301, 302, 303, 307, 308].includes(reply.status) &&
reply.headers.has('location')
) {
// A followed redirect is a routing hop, not a billable model
// call. Wait until the redirect checks decide to return it.
redirectBilling = { receipt, status: reply.status }
return reply
}
billing = options.billing?.(receipt)
return observeBillingResponse(billing, reply)
} catch (error) {
const failedBilling = billing ?? options.billing?.(receipt)
failedBilling?.end(false, false)
throw error
const dispatch = async (
receipt: ClaustrumScopedAttempt,
dispatchSignal?: AbortSignal,
) => {
if (routed && receipt.credentialId !== routed.credentialId)
throw new ClaustrumAuthError('routing-no-answer')
secrets.push(receipt.accessToken)
const draft = placeReceipt(target, headers, options.entry, receipt)
const dispatchHeaders = new Headers(draft.headers)
if (!bytes) {
dispatchHeaders.delete('content-length')
if (method !== template.method)
dispatchHeaders.delete('content-type')
}
const outbound = new Request(draft.url, {
method,
headers: dispatchHeaders,
body: bytes ? new Uint8Array(bytes) : undefined,
signal: dispatchSignal ?? signal,
redirect: 'manual',
credentials: template.credentials,
cache: template.cache,
})
let billing: BillingAttempt | undefined
try {
const reply = await options.fetch(outbound)
if (
options.billing &&
[301, 302, 303, 307, 308].includes(reply.status) &&
reply.headers.has('location')
) {
// A followed redirect is a routing hop, not a billable model
// call. Wait until the redirect checks decide to return it.
redirectBilling = { receipt, status: reply.status }
return reply
}
},
{ site: 'claustrum-auth', signal },
)
billing = options.billing?.(receipt)
return observeBillingResponse(billing, reply)
} catch (error) {
const failedBilling = billing ?? options.billing?.(receipt)
failedBilling?.end(false, false)
throw error
}
}
response = await options.consumer.send(route, dispatch, {
site: 'claustrum-auth',
signal,
})
signal.throwIfAborted()
const location = response.headers.get('location')
if (![301, 302, 303, 307, 308].includes(response.status) || !location)
Expand Down Expand Up @@ -322,15 +356,26 @@ export function createFetch(options: FetchOptions): CoreFetch {
redirectBilling = undefined
response = observeBillingResponse(billing, response)
}
const account = observation?.binding.account ?? {
...initial.account,
accountId: route,
}
const parsed = await limitFromReply({
account: observation?.binding.account ?? initial.account,
account,
service: options.entry.service,
status: response.status,
headers: response.headers,
body: () => response.clone().text(),
now: now(),
})
const balance402 = parsed?.kind === 'payment-balance'
await reportRoutedBalance(
routed,
options.routing?.sessionId,
options.entry.service,
response.status,
() => response.clone().text(),
)
if (response.ok || (response.status === 402 && !balance402)) {
if (limits && observation) {
if (
Expand Down Expand Up @@ -360,7 +405,7 @@ export function createFetch(options: FetchOptions): CoreFetch {
}
pending = {
response,
account: observation?.binding.account ?? initial.account,
account,
parsed,
}
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ export {
reservedProviders,
supportedProviders,
} from './providers.js'
export type { RoutedKey } from './routing.js'
export {
hasVaultRoutingAccessor,
reportRoutedBalance,
routingDeclinedText,
routingNoAnswerText,
routingRefusalText,
selectRoutedKey,
} from './routing.js'
export type { CoreOptions, RosterCore } from './runtime.js'
export { createCore, unsavedRosterCause } from './runtime.js'
export type { ServingConsumer, ServingOptions } from './serving.js'
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/placement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type Refusal =
| 'route-gone'
| 'vault-mode-off'
| 'consent-required'
| 'routing-declined'
| 'routing-no-answer'

export class ClaustrumAuthError extends Error {
constructor(readonly code: Refusal) {
Expand Down
Loading
Loading