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
13 changes: 13 additions & 0 deletions packages/fold-agent/src/Catalog/BakedCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,19 @@ export const bakedModelCatalog: ReadonlyArray<ModelCatalogEntry> = [
toolCall: true,
pricing: { inputPerMTokens: 2.5, outputPerMTokens: 15, cacheReadPerMTokens: 0.25, cacheWritePerMTokens: 3.125 },
},
{
providerId: 'openai',
modelId: 'gpt-6-astra',
name: 'GPT-6 Astra',
contextWindow: 1050000,
maxInputTokens: 922000,
maxOutputTokens: 128000,
reasoning: true,
reasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
vision: true,
toolCall: true,
pricing: { inputPerMTokens: 10, outputPerMTokens: 50, cacheReadPerMTokens: 1, cacheWritePerMTokens: 12.5 },
},
{
providerId: 'openai',
modelId: 'gpt-image-1',
Expand Down
2 changes: 1 addition & 1 deletion packages/fold-agent/src/Config/AgentModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* which carries no key (it uses `~/.fold/auth.json`).
*
* `orchestrator` falls back to `smart` when unbound (D25). A binding without a model fills the provider
* kind's default (codex → gpt-5.6-sol, anthropic → claude-opus-4-8); openai-compat has no default and
* kind's default (codex → gpt-6-astra, anthropic → claude-opus-4-8); openai-compat has no default and
* requires an explicit model. Provider cross-references were validated at decode (ConfigSchema), so
* remaining resolution failures are a missing credential env var, a model-less openai-compat binding,
* or - when a model catalog is provided - a reasoning level the bound model does not support (D23:
Expand Down
2 changes: 1 addition & 1 deletion packages/fold-agent/src/Config/ConfigSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const RoleBinding = Schema.Struct({
model: Schema.optionalKey(
Schema.String.annotate({
description:
'Provider model id; defaults: codex/opencode → gpt-5.6-sol, xai → grok-4.6, anthropic → claude-opus-4-8; required for openai-compat',
'Provider model id; defaults: codex → gpt-6-astra, opencode → gpt-5.6-sol, xai → grok-4.6, anthropic → claude-opus-4-8; required for openai-compat',
}),
),
/** Reasoning level for this role's requests. Defaults to `off` when omitted. */
Expand Down
7 changes: 4 additions & 3 deletions packages/fold-agent/src/Config/ConfigSchemaJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,14 @@ export const starterConfigJsonc = (): string =>

// Model roles bound to a provider (+ optional model). "smart" and "fast" are required;
// "orchestrator" is optional and falls back to "smart". Omitting "model" uses the provider
// defaults: codex/opencode → gpt-5.6-sol, xai → grok-4.6, anthropic → claude-opus-4-8.
// defaults: codex → gpt-6-astra, opencode → gpt-5.6-sol, xai → grok-4.6,
// anthropic → claude-opus-4-8.
"roles": {
// Default primary model for new sessions.
"smart": { "provider": "codex", "model": "gpt-5.6-sol", "reasoning": "medium" },
"smart": { "provider": "codex", "model": "gpt-6-astra", "reasoning": "medium" },
// An explicit model always wins over the provider default.
"fast": { "provider": "anthropic", "model": "claude-haiku-4-5-20251001" },
// No model: uses the codex default, gpt-5.6-sol.
// No model: uses the codex default, gpt-6-astra.
"orchestrator": { "provider": "codex", "reasoning": "high" }
},

Expand Down
6 changes: 3 additions & 3 deletions packages/fold-agent/src/Config/FoldInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,14 @@ is optional and falls back to \`smart\`.
| \`orchestrator\` | the RLM mode's primary agent |

Each binding is \`{ "provider": <key>, "model"?: <id>, "reasoning"?: <level> }\`. Omitting \`model\`
uses the provider kind's default: codex/opencode → \`gpt-5.6-sol\`, xai → \`grok-4.6\`, and
uses the provider kind's default: codex → \`gpt-6-astra\`, opencode → \`gpt-5.6-sol\`, xai → \`grok-4.6\`, and
anthropic → \`claude-opus-4-8\`
(openai-compat requires an explicit model). \`reasoning\` is one of \`off · minimal · low · medium ·
high · xhigh · max\` and is validated against the model catalog - a level the model does not support
fails the launch naming the supported levels. All roles referenced by a launch resolve eagerly, so
every binding needs working credentials.

The starter config defaults \`smart\` to Codex \`gpt-5.6-sol\` with \`medium\` reasoning, so a new
The starter config defaults \`smart\` to Codex \`gpt-6-astra\` with \`medium\` reasoning, so a new
default-mode session uses that model unless the user selects a profile or direct model override.

## Profiles - named presets (roles + optional mode)
Expand Down Expand Up @@ -143,7 +143,7 @@ foldcode --prompt "..." # one-shot run; exit codes 0/130/1
foldcode --profile ultracodex # profile's roles + its pinned mode (rlm)
foldcode --mode rlm --rpi # explicit mode + RPI roster
foldcode --role fast --reasoning off # primary on the fast role, reasoning off
foldcode --provider codex # kind change drops the stale model -> gpt-5.6-sol default
foldcode --provider codex # kind change drops the stale model -> gpt-6-astra default
foldcode --resume latest|sess_* # resume (config re-resolves; drift writes a durable transition)
foldcode sessions # list this project's session logs
foldcode config init|validate # starter config + schema | validate
Expand Down
2 changes: 1 addition & 1 deletion packages/fold-agent/src/Config/ModelSelections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const describeModelConfiguration = (
.filter((entry) => catalogProviderIds.includes(entry.providerId))
.map((entry) => entry.modelId)
const defaultModels = Match.value(provider.kind).pipe(
Match.when('codex', () => [DEFAULT_OPENCODE_MODEL_ID]),
Match.when('codex', () => [DEFAULT_CODEX_MODEL_ID]),
Match.when('opencode', () => [DEFAULT_OPENCODE_MODEL_ID, GROK_BUILD_MODEL_ID]),
Match.when('xai', () => XAI_FRONTIER_MODELS.map(({ modelId }) => modelId)),
Match.orElse((): ReadonlyArray<string> => []),
Expand Down
24 changes: 23 additions & 1 deletion packages/fold-agent/test/Catalog/BakedCatalog.vi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Baked-catalog resolution tests for the gpt-5.6 model family (D23): the codex provider kind maps
* Baked-catalog resolution tests for current Codex models (D23): the codex provider kind maps
* onto the models.dev `openai` provider id, so a codex-kind ActiveModel resolves the same catalog
* entry an openai-compatible one does. Asserting limits, pricing, and the `max` effort level against
* the shipped data proves the entries that make `reasoning: max` valid for these models are live.
Expand All @@ -19,6 +19,15 @@ const codexSol: ActiveModel = {
reasoning: { _tag: 'effort', effort: 'max', summary: 'auto' },
}

const codexAstra: ActiveModel = {
providerId: 'codex',
providerKind: 'codex',
modelId: 'gpt-6-astra',
role: null,
requestedReasoningLevel: 'max',
reasoning: { _tag: 'effort', effort: 'max', summary: 'auto' },
}

const openAiTerra: ActiveModel = {
providerId: 'openai',
providerKind: 'openai-compatible',
Expand Down Expand Up @@ -49,6 +58,19 @@ it('resolves a codex-kind gpt-5.6-sol to the baked openai entry', () => {
expect(entry?.reasoningEfforts).toContain('max')
})

it('resolves a codex-kind gpt-6-astra to the baked OpenAI entry', () => {
const entry = lookupCatalogEntry(bakedModelCatalog, codexAstra)

expect(entry).not.toBeNull()
expect(entry?.providerId).toBe('openai')
expect(entry?.modelId).toBe('gpt-6-astra')
expect(entry?.contextWindow).toBe(1_050_000)
expect(entry?.maxInputTokens).toBe(922_000)
expect(entry?.pricing?.inputPerMTokens).toBe(10)
expect(entry?.pricing?.outputPerMTokens).toBe(50)
expect(entry?.reasoningEfforts).toContain('max')
})

it('resolves an openai-compatible gpt-5.6-terra to the baked openai entry', () => {
const entry = lookupCatalogEntry(bakedModelCatalog, openAiTerra)

Expand Down
8 changes: 4 additions & 4 deletions packages/fold-agent/test/Config/AgentModels.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ const defaultsConfigText = `{
}
}`

it.effect('a codex binding without a model resolves to the gpt-5.6-sol default', () =>
it.effect('a codex binding without a model resolves to the gpt-6-astra default', () =>
Effect.gen(function* () {
const config = yield* parseFoldConfig(defaultsConfigText)
const models = agentModelsFromConfig(config, {
Expand All @@ -304,8 +304,8 @@ it.effect('a codex binding without a model resolves to the gpt-5.6-sol default',

const model = yield* models.resolve('smart')
expect(model.activeModel.providerKind).toBe('codex')
expect(model.activeModel.modelId).toBe('gpt-5.6-sol')
// Catalog validation ran against the defaulted id: sol supports 'max'.
expect(model.activeModel.modelId).toBe('gpt-6-astra')
// Catalog validation ran against the defaulted id: Astra supports 'max'.
expect(model.activeModel.requestedReasoningLevel).toBe('max')
}),
)
Expand Down Expand Up @@ -336,7 +336,7 @@ it.effect('an openai-compat binding without a model fails with the required-mode
expect(error._tag).toBe('RoleResolutionError')
expect(error.role).toBe('orchestrator')
expect(error.message).toContain('without a model')
expect(error.message).toContain('codex → gpt-5.6-sol')
expect(error.message).toContain('codex → gpt-6-astra')
expect(error.message).toContain('anthropic → claude-opus-4-8')
expect(error.message).toContain('required for openai-compat')
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ it.effect('the starter config is valid against the schema (round-trips through t

expect(config.roles.smart).toMatchObject({
provider: 'codex',
model: 'gpt-5.6-sol',
model: 'gpt-6-astra',
reasoning: 'medium',
})
expect(config.providers.codex?.kind).toBe('codex')
Expand Down
4 changes: 2 additions & 2 deletions packages/fold-agent/test/Config/ModelSelections.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ it.effect('applies provider-specific role defaults around the directly selected
expect(normal.root.activeModel.role).toBe('smart')
expect(normal.smart.activeModel.modelId).toBe('chosen')
expect(normal.fast.activeModel).toMatchObject({ providerId: 'codex', modelId: 'gpt-5.6-luna' })
expect(normal.orchestrator.activeModel).toMatchObject({ providerId: 'codex', modelId: 'gpt-5.6-sol' })
expect(normal.orchestrator.activeModel).toMatchObject({ providerId: 'codex', modelId: 'gpt-6-astra' })
expect(rlm.root.activeModel.modelId).toBe('chosen')
expect(rlm.root.activeModel.role).toBe('orchestrator')
expect(rlm.smart.activeModel).toMatchObject({ providerId: 'codex', modelId: 'gpt-5.6-terra' })
Expand Down Expand Up @@ -153,7 +153,7 @@ it.effect('describes profiles, credentials, and merged model candidates without
expect(description.providers.find(({ name }) => name === 'codex')?.models).toEqual([
'catalog-only',
'default-fast',
'gpt-5.6-sol',
'gpt-6-astra',
'profile-fast',
'profile-orchestrator',
'profile-smart',
Expand Down
2 changes: 1 addition & 1 deletion packages/fold-agent/test/Config/ProviderConfig.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ it.effect('adds OAuth profiles without an API key and supplies their default mod
expect(updated.providers['work-codex']).toEqual({
kind: 'codex',
baseUrl: 'https://example.test',
configuredModels: ['gpt-5.6-sol'],
configuredModels: ['gpt-6-astra'],
})
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
Expand Down
2 changes: 1 addition & 1 deletion packages/fold-agent/test/Mode/Launch.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ it.effect('mergeModelSelection drops the stale model when the newly named provid
const config = yield* parseFoldConfig(mergeConfigText)

// `fold --provider codex` alone: the anthropic model id must NOT ride onto codex - the binding
// comes back model-less so the codex default (gpt-5.6-sol) applies at resolution.
// comes back model-less so the codex default (gpt-6-astra) applies at resolution.
const merged = mergeModelSelection(config, config.roles.smart, { provider: 'codex' })
expect(merged).toEqual({ provider: 'codex', reasoning: 'medium' })
expect(merged.model).toBeUndefined()
Expand Down
27 changes: 27 additions & 0 deletions packages/fold-cli/src/ContextWindow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defaultContextWindowFor, type ActiveModel } from '@humanlayer/fold-core'

const CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95
const CODEX_BASELINE_TOKENS = 12_000

export const contextWindowLimitForDisplay = (model: ActiveModel, catalogContextWindow?: number): number => {
const rawLimit =
model.providerKind === 'codex'
? defaultContextWindowFor(model.modelId)
: (catalogContextWindow ?? defaultContextWindowFor(model.modelId))

return model.providerKind === 'codex'
? Math.floor((rawLimit * CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT) / 100)
: rawLimit
}

export const contextUsedPercentForDisplay = (used: number, model: ActiveModel, limit: number): number => {
if (model.providerKind !== 'codex') return Math.min(100, Math.round((used / limit) * 100))
if (limit <= CODEX_BASELINE_TOKENS) return 100

const effectiveWindow = limit - CODEX_BASELINE_TOKENS
const effectiveUsed = Math.max(0, used - CODEX_BASELINE_TOKENS)
const remaining = Math.max(0, effectiveWindow - effectiveUsed)
const remainingPercent = Math.round((remaining / effectiveWindow) * 100)

return 100 - remainingPercent
}
6 changes: 3 additions & 3 deletions packages/fold-cli/src/Renderer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { decodeBashOutputDelta } from '@humanlayer/fold-agent'
import {
defaultContextWindowFor,
lookupCatalogEntry,
shortAgentId,
usageCacheRead,
Expand All @@ -21,6 +20,7 @@ import {
import { Data, Effect, Match } from 'effect'

import { makeAnsiPalette, type AnsiPalette } from './Ansi'
import { contextUsedPercentForDisplay, contextWindowLimitForDisplay } from './ContextWindow'

type Writer = (text: string) => Effect.Effect<void>

Expand Down Expand Up @@ -249,8 +249,8 @@ const contextText = (usage: UsageEncoded, model: ActiveModel | null, entry: Mode
const used = usageInputTotal(usage) + usageOutputTotal(usage)
if (model === null) return formatInt(used)

const limit = entry?.contextWindow ?? defaultContextWindowFor(model.modelId)
const percent = Math.round((used / limit) * 100)
const limit = contextWindowLimitForDisplay(model, entry?.contextWindow)
const percent = contextUsedPercentForDisplay(used, model, limit)
return `${formatInt(used)}/${formatInt(limit)} (${percent}%)`
}

Expand Down
14 changes: 10 additions & 4 deletions packages/fold-cli/src/tui/TuiSessionWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { layerLiveIdFactory, lookupCatalogEntry, type SessionId, type FoldSessio
import { Cause, Duration, Effect, type FileSystem, Match, Option, Scope } from 'effect'
import { createSignal, type Accessor } from 'solid-js'

import { contextUsedPercentForDisplay, contextWindowLimitForDisplay } from '../ContextWindow'
import { makeHostedTuiSession, type HostedTuiSession, type HostedTuiSessionMetadata } from './HostedTuiSession'
import { requestToLaunchOptions, sessionToLaunchOptions } from './LaunchRequests'
import { makeLiveSessionHost } from './LiveSessionHost'
Expand Down Expand Up @@ -100,14 +101,19 @@ export const makeTuiSessionWorkspace = (options: {
summary.model === null || options.tui.catalog === undefined
? null
: lookupCatalogEntry(options.tui.catalog, summary.model)
const contextWindow =
summary.model === null || entry === null || entry.contextWindow <= 0
? null
: contextWindowLimitForDisplay(summary.model, entry.contextWindow)
return {
...summary,
contextPercent:
summary.contextTokens === null || entry === null || entry.contextWindow <= 0
summary.contextTokens === null || summary.model === null || contextWindow === null
? null
: Math.min(
100,
Math.round((summary.contextTokens / entry.contextWindow) * 100),
: contextUsedPercentForDisplay(
summary.contextTokens,
summary.model,
contextWindow,
),
}
})
Expand Down
24 changes: 24 additions & 0 deletions packages/fold-cli/test/ContextWindow.vi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ActiveModel } from '@humanlayer/fold-core'
import { expect, it } from 'vitest'

import { contextUsedPercentForDisplay, contextWindowLimitForDisplay } from '../src/ContextWindow'

const astra: ActiveModel = {
providerId: 'codex',
providerKind: 'codex' as const,
modelId: 'gpt-6-astra',
role: null,
requestedReasoningLevel: 'max' as const,
reasoning: { _tag: 'effort', effort: 'max', summary: 'auto' },
}

it('shows the Codex usable Astra window instead of the public API catalog window', () => {
expect(contextWindowLimitForDisplay(astra, 1_050_000)).toBe(258_400)
})

it('matches the Codex gauge percentage after excluding its baseline tokens', () => {
const limit = contextWindowLimitForDisplay(astra, 1_050_000)

expect(contextUsedPercentForDisplay(12_000, astra, limit)).toBe(0)
expect(contextUsedPercentForDisplay(240_000, astra, limit)).toBe(93)
})
4 changes: 2 additions & 2 deletions packages/fold-codex/src/CodexModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const CODEX_API_URL = 'https://chatgpt.com/backend-api/codex'
export const DEFAULT_REQUEST_RETRY_TIMES = 3

/** The codex model used when {@link CodexModelOptions.model} is omitted. */
export const DEFAULT_CODEX_MODEL_ID = 'gpt-5.6-sol'
export const DEFAULT_CODEX_MODEL_ID = 'gpt-6-astra'

type ResponsesPayload = Omit<typeof OpenAiSchema.CreateResponse.Encoded, 'stream'>
type ResponseBody = typeof OpenAiSchema.Response.Type
Expand Down Expand Up @@ -216,7 +216,7 @@ export const decorateCodexClient = (inner: OpenAiClient.Service, options: CodexR

/** Options for {@link codexModel}. */
export type CodexModelOptions = {
/** Codex model id, for example `gpt-5.6-sol`. Defaults to {@link DEFAULT_CODEX_MODEL_ID}. */
/** Codex model id, for example `gpt-6-astra`. Defaults to {@link DEFAULT_CODEX_MODEL_ID}. */
readonly model?: string
/** Reasoning level for requests. Defaults to `off`, which leaves the backend default untouched. */
readonly reasoning?: ReasoningLevel
Expand Down
2 changes: 1 addition & 1 deletion packages/fold-codex/test/CodexModel.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ describe('codexModel defaults', () => {
it('omitting the model binds the default codex model id', () => {
const model = codexModel({})

expect(DEFAULT_CODEX_MODEL_ID).toBe('gpt-5.6-sol')
expect(DEFAULT_CODEX_MODEL_ID).toBe('gpt-6-astra')
expect(model.activeModel.modelId).toBe(DEFAULT_CODEX_MODEL_ID)
expect(model.activeModel.providerKind).toBe('codex')
})
Expand Down
1 change: 1 addition & 0 deletions packages/fold-core/src/Compaction/CompactionEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const filePartEstimateChars = 4_800
*/
export const defaultModelContextWindows: ReadonlyArray<readonly [RegExp, number]> = [
[/claude|fable|mythos/, 200_000],
[/gpt-6-astra/, 272_000],
[/gpt-5|codex/, 272_000],
[/gpt-4\.1/, 1_000_000],
]
Expand Down
7 changes: 4 additions & 3 deletions packages/fold-core/src/Compaction/CompactionLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,14 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact
const turnPrefixOutputFraction = 0.5

/**
* Resolve the agent's context window: an explicit `autoCompact.contextWindow` always wins, then
* the session's ModelCatalog entry for the active model, then the interim pattern table (D15).
* The Reference default is the empty catalog, so this adds nothing to the effect requirements.
* Resolve the agent's context window: an explicit `autoCompact.contextWindow` always wins. Codex
* uses its app-specific window because the public OpenAI API catalog can advertise a larger limit;
* other providers use the session ModelCatalog before the interim pattern table (D15).
*/
const contextWindowFor = (input: CompactionCheckInput): Effect.Effect<number> =>
Effect.gen(function* () {
if (config.contextWindow !== undefined) return config.contextWindow
if (input.model?.providerKind === 'codex') return defaultContextWindowFor(input.model.modelId)

const entry = input.model === null ? null : yield* (yield* ModelCatalog).lookup(input.model)

Expand Down
Loading
Loading