Skip to content

Commit 4d5d478

Browse files
committed
feat: default to gpt-5.4 and seed project storage fallback
- default unknown/empty model fallback to gpt-5.4 across transform and runtime family selection - load global account storage when project storage is missing, then seed project-scoped storage - align tests and internal testing docs with the new fallback behavior
1 parent bd79669 commit 4d5d478

File tree

9 files changed

+149
-35
lines changed

9 files changed

+149
-35
lines changed

docs/development/TESTING.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,9 @@ API receives: "gpt-5-codex" ✅
248248
```
249249
User selects: (none - uses OpenCode default)
250250
Plugin receives: undefined or default from OpenCode
251-
normalizeModel: undefined → "gpt-5.1" ✅ (fallback)
251+
normalizeModel: undefined → "gpt-5.4" ✅ (fallback)
252252
Config lookup: models[undefined] → undefined
253-
API receives: "gpt-5.1" ✅
253+
API receives: "gpt-5.4" ✅
254254
```
255255

256256
**Result:** ✅ Works (safe fallback)
@@ -621,14 +621,14 @@ normalizeModel("gpt-5") // → "gpt-5.4" ✅
621621
normalizeModel("gpt-5-mini") // → "gpt-5.4" ✅
622622
normalizeModel("gpt-5-nano") // → "gpt-5.4" ✅
623623
normalizeModel("GPT 5.4 Pro High") // → "gpt-5.4-pro" ✅
624-
normalizeModel(undefined) // → "gpt-5.1" ✅
625-
normalizeModel("random-model") // → "gpt-5.1" ✅ (fallback)
624+
normalizeModel(undefined) // → "gpt-5.4" ✅
625+
normalizeModel("random-model") // → "gpt-5.4" ✅ (fallback)
626626
```
627627

628628
**Implementation:**
629629
```typescript
630630
export function normalizeModel(model: string | undefined): string {
631-
if (!model) return "gpt-5.1";
631+
if (!model) return "gpt-5.4";
632632

633633
const modelId = model.includes("/") ? model.split("/").pop() ?? model : model;
634634
const mappedModel = getNormalizedModel(modelId);
@@ -660,14 +660,14 @@ export function normalizeModel(model: string | undefined): string {
660660
if (normalized.includes("gpt-5") || normalized.includes("gpt 5")) {
661661
return "gpt-5.4";
662662
}
663-
return "gpt-5.1";
663+
return "gpt-5.4";
664664
}
665665
```
666666

667667
**Why this works:**
668668
- ✅ Case-insensitive (`.toLowerCase()` + `.includes()`)
669669
- ✅ Pattern-based (works with any naming)
670-
- ✅ Legacy GPT-5 fallback (`gpt-5*` aliases → `gpt-5.4`) + safe unknown fallback (`gpt-5.1`)
670+
- ✅ Legacy GPT-5 fallback (`gpt-5*` aliases → `gpt-5.4`) + safe unknown fallback (`gpt-5.4`)
671671
- ✅ Codex priority with explicit Codex Mini support (`codex-mini*``codex-mini-latest`)
672672

673673
---
@@ -741,9 +741,9 @@ describe('normalizeModel', () => {
741741
})
742742

743743
test('handles edge cases', () => {
744-
expect(normalizeModel(undefined)).toBe('gpt-5.1')
744+
expect(normalizeModel(undefined)).toBe('gpt-5.4')
745745
expect(normalizeModel('codex-mini-latest')).toBe('gpt-5.1-codex-mini')
746-
expect(normalizeModel('random')).toBe('gpt-5.1')
746+
expect(normalizeModel('random')).toBe('gpt-5.4')
747747
})
748748
})
749749

index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,7 +1896,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
18961896
const url = rewriteUrlForCodex(originalUrl);
18971897

18981898
// Step 3: Transform request body with model-specific Codex instructions
1899-
// Instructions are fetched per model family (codex-max, codex, gpt-5.1)
1899+
// Instructions are fetched per model family (codex-max, codex, gpt-5.4, etc.)
19001900
// Capture original stream value before transformation
19011901
// generateText() sends no stream field, streamText() sends stream=true
19021902
const normalizeRequestInit = async (
@@ -1986,7 +1986,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
19861986
let transformedBody: RequestBody | undefined = transformation?.body;
19871987
const promptCacheKey = transformedBody?.prompt_cache_key;
19881988
let model = transformedBody?.model;
1989-
let modelFamily = model ? getModelFamily(model) : "gpt-5.1";
1989+
let modelFamily = model ? getModelFamily(model) : "gpt-5.4";
19901990
let quotaKey = model ? `${modelFamily}:${model}` : modelFamily;
19911991
const threadIdCandidate =
19921992
(process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "")

lib/request/request-transformer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export {
3838
* @returns Normalized model name (e.g., "gpt-5-codex", "gpt-5.1-codex-max")
3939
*/
4040
export function normalizeModel(model: string | undefined): string {
41-
if (!model) return "gpt-5.1";
41+
if (!model) return "gpt-5.4";
4242

4343
// Strip provider prefix if present (e.g., "openai/gpt-5-codex" → "gpt-5-codex")
4444
const modelId = model.includes("/") ? model.split("/").pop() ?? model : model;
@@ -151,7 +151,7 @@ export function normalizeModel(model: string | undefined): string {
151151
}
152152

153153
// Default fallback
154-
return "gpt-5.1";
154+
return "gpt-5.4";
155155
}
156156

157157
/**

lib/storage.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,58 @@ export async function loadAccounts(): Promise<AccountStorageV3 | null> {
669669
return loadAccountsInternal(saveAccounts);
670670
}
671671

672+
function getGlobalAccountsStoragePath(): string {
673+
return join(getConfigDir(), ACCOUNTS_FILE_NAME);
674+
}
675+
676+
function shouldUseProjectGlobalFallback(): boolean {
677+
return Boolean(currentStoragePath && currentProjectRoot);
678+
}
679+
680+
async function loadGlobalAccountsFallback(): Promise<AccountStorageV3 | null> {
681+
if (!shouldUseProjectGlobalFallback() || !currentStoragePath) {
682+
return null;
683+
}
684+
685+
const globalStoragePath = getGlobalAccountsStoragePath();
686+
if (globalStoragePath === currentStoragePath) {
687+
return null;
688+
}
689+
690+
try {
691+
const content = await fs.readFile(globalStoragePath, "utf-8");
692+
const data = JSON.parse(content) as unknown;
693+
694+
const schemaErrors = getValidationErrors(AnyAccountStorageSchema, data);
695+
if (schemaErrors.length > 0) {
696+
log.warn("Global account storage schema validation warnings", {
697+
path: globalStoragePath,
698+
errors: schemaErrors.slice(0, 5),
699+
});
700+
}
701+
702+
const normalized = normalizeAccountStorage(data);
703+
if (!normalized) return null;
704+
705+
log.info("Loaded global account storage as project fallback", {
706+
from: globalStoragePath,
707+
to: currentStoragePath,
708+
accounts: normalized.accounts.length,
709+
});
710+
return normalized;
711+
} catch (error) {
712+
const code = (error as NodeJS.ErrnoException).code;
713+
if (code !== "ENOENT") {
714+
log.warn("Failed to load global fallback account storage", {
715+
from: globalStoragePath,
716+
to: currentStoragePath,
717+
error: String(error),
718+
});
719+
}
720+
return null;
721+
}
722+
}
723+
672724
async function loadAccountsInternal(
673725
persistMigration: ((storage: AccountStorageV3) => Promise<void>) | null,
674726
): Promise<AccountStorageV3 | null> {
@@ -704,7 +756,25 @@ async function loadAccountsInternal(
704756
? await migrateLegacyProjectStorageIfNeeded(persistMigration)
705757
: null;
706758
if (migrated) return migrated;
707-
return null;
759+
const globalFallback = await loadGlobalAccountsFallback();
760+
if (!globalFallback) return null;
761+
762+
if (persistMigration) {
763+
try {
764+
await persistMigration(globalFallback);
765+
log.info("Seeded project account storage from global fallback", {
766+
path: getStoragePath(),
767+
accounts: globalFallback.accounts.length,
768+
});
769+
} catch (persistError) {
770+
log.warn("Failed to seed project storage from global fallback", {
771+
path: getStoragePath(),
772+
error: String(persistError),
773+
});
774+
}
775+
}
776+
777+
return globalFallback;
708778
}
709779
log.error("Failed to load account storage", { error: String(error) });
710780
return null;

test/edge-cases.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ import type { InputItem, UserConfig } from "../lib/types.js";
1818
describe("Edge Cases and Boundary Conditions", () => {
1919
describe("normalizeModel edge cases", () => {
2020
it("should handle null-like values", () => {
21-
expect(normalizeModel(undefined)).toBe("gpt-5.1");
22-
expect(normalizeModel("")).toBe("gpt-5.1");
23-
expect(normalizeModel(" ")).toBe("gpt-5.1");
21+
expect(normalizeModel(undefined)).toBe("gpt-5.4");
22+
expect(normalizeModel("")).toBe("gpt-5.4");
23+
expect(normalizeModel(" ")).toBe("gpt-5.4");
2424
});
2525

2626
it("should handle models with multiple slashes", () => {
@@ -40,13 +40,13 @@ describe("Edge Cases and Boundary Conditions", () => {
4040

4141
it("should handle models with mixed separators", () => {
4242
// Mixed separators may miss explicit 5.4/pro patterns and fall through to generic GPT-5 fallback.
43-
expect(normalizeModel("gpt_5.4-high")).toBe("gpt-5.1");
43+
expect(normalizeModel("gpt_5.4-high")).toBe("gpt-5.4");
4444
expect(normalizeModel("gpt-5_4 pro")).toBe("gpt-5.4");
4545
});
4646

4747
it("should handle models with numeric-only names", () => {
48-
expect(normalizeModel("5.4")).toBe("gpt-5.1");
49-
expect(normalizeModel("5")).toBe("gpt-5.1");
48+
expect(normalizeModel("5.4")).toBe("gpt-5.4");
49+
expect(normalizeModel("5")).toBe("gpt-5.4");
5050
});
5151

5252
it("should handle models with unicode characters", () => {

test/gpt54-models.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,13 @@ describe("GPT-5.4 Model Support", () => {
219219

220220
it("should handle gpt-5.4 with multiple spaces", () => {
221221
// Multiple spaces are not explicitly handled, falls back to default
222-
expect(normalizeModel("gpt 5.4")).toBe("gpt-5.1");
223-
expect(normalizeModel("gpt 5.4 high")).toBe("gpt-5.1");
222+
expect(normalizeModel("gpt 5.4")).toBe("gpt-5.4");
223+
expect(normalizeModel("gpt 5.4 high")).toBe("gpt-5.4");
224224
});
225225

226226
it("should handle gpt-5.4 with underscore separator", () => {
227227
// Underscore separator not explicitly supported, falls back to default
228-
expect(normalizeModel("gpt_5_4")).toBe("gpt-5.1");
228+
expect(normalizeModel("gpt_5_4")).toBe("gpt-5.4");
229229
});
230230

231231
it("should not match gpt-5.4x patterns as gpt-5.4", () => {
@@ -234,9 +234,9 @@ describe("GPT-5.4 Model Support", () => {
234234
expect(normalizeModel("gpt-5.44")).toBe("gpt-5.4");
235235
});
236236

237-
it("should handle empty/undefined model names defaulting to gpt-5.1", () => {
238-
expect(normalizeModel(undefined)).toBe("gpt-5.1");
239-
expect(normalizeModel("")).toBe("gpt-5.1");
237+
it("should handle empty/undefined model names defaulting to gpt-5.4", () => {
238+
expect(normalizeModel(undefined)).toBe("gpt-5.4");
239+
expect(normalizeModel("")).toBe("gpt-5.4");
240240
});
241241
});
242242

test/property/transformer.property.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ describe("normalizeModel property tests", () => {
4747

4848
it("handles undefined gracefully", () => {
4949
const result = normalizeModel(undefined);
50-
expect(result).toBe("gpt-5.1");
50+
expect(result).toBe("gpt-5.4");
5151
});
5252

5353
it("handles empty string gracefully", () => {
5454
const result = normalizeModel("");
55-
expect(result).toBe("gpt-5.1");
55+
expect(result).toBe("gpt-5.4");
5656
});
5757
});
5858

test/request-transformer.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ describe('Request Transformer Module', () => {
3535
expect(normalizeModel('gpt-5-nano')).toBe('gpt-5.4');
3636
});
3737

38-
it('should return gpt-5.1 as default for unknown models', async () => {
39-
expect(normalizeModel('unknown-model')).toBe('gpt-5.1');
40-
expect(normalizeModel('gpt-4')).toBe('gpt-5.1');
38+
it('should return gpt-5.4 as default for unknown models', async () => {
39+
expect(normalizeModel('unknown-model')).toBe('gpt-5.4');
40+
expect(normalizeModel('gpt-4')).toBe('gpt-5.4');
4141
});
4242

43-
it('should return gpt-5.1 for undefined', async () => {
44-
expect(normalizeModel(undefined)).toBe('gpt-5.1');
43+
it('should return gpt-5.4 for undefined', async () => {
44+
expect(normalizeModel(undefined)).toBe('gpt-5.4');
4545
});
4646

4747
// Codex CLI preset name tests - legacy gpt-5 base aliases now map to gpt-5.4
@@ -173,7 +173,7 @@ describe('Request Transformer Module', () => {
173173

174174
it('should handle special characters', async () => {
175175
expect(normalizeModel('my_gpt-5_codex')).toBe('gpt-5-codex');
176-
expect(normalizeModel('gpt.5.high')).toBe('gpt-5.1');
176+
expect(normalizeModel('gpt.5.high')).toBe('gpt-5.4');
177177
});
178178

179179
it('should handle old verbose names', async () => {
@@ -182,7 +182,7 @@ describe('Request Transformer Module', () => {
182182
});
183183

184184
it('should handle empty string', async () => {
185-
expect(normalizeModel('')).toBe('gpt-5.1');
185+
expect(normalizeModel('')).toBe('gpt-5.4');
186186
});
187187
});
188188
});

test/storage.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1780,6 +1780,50 @@ describe("storage", () => {
17801780
expect(existsSync(legacyStoragePath)).toBe(false);
17811781
expect(existsSync(getStoragePath())).toBe(true);
17821782
});
1783+
1784+
it("loads global storage as fallback when project-scoped storage is missing", async () => {
1785+
const fakeHome = join(testWorkDir, "home-fallback");
1786+
const projectDir = join(testWorkDir, "project-fallback");
1787+
const projectGitDir = join(projectDir, ".git");
1788+
const globalConfigDir = join(fakeHome, ".opencode");
1789+
const globalStoragePath = join(globalConfigDir, "openai-codex-accounts.json");
1790+
1791+
await fs.mkdir(fakeHome, { recursive: true });
1792+
await fs.mkdir(projectGitDir, { recursive: true });
1793+
await fs.mkdir(globalConfigDir, { recursive: true });
1794+
process.env.HOME = fakeHome;
1795+
process.env.USERPROFILE = fakeHome;
1796+
setStoragePath(projectDir);
1797+
1798+
const globalStorage = {
1799+
version: 3,
1800+
activeIndex: 0,
1801+
accounts: [
1802+
{
1803+
refreshToken: "global-refresh",
1804+
accountId: "global-account",
1805+
addedAt: 1,
1806+
lastUsed: 1,
1807+
},
1808+
],
1809+
};
1810+
await fs.writeFile(globalStoragePath, JSON.stringify(globalStorage), "utf-8");
1811+
1812+
const loaded = await loadAccounts();
1813+
1814+
expect(loaded).not.toBeNull();
1815+
expect(loaded?.accounts).toHaveLength(1);
1816+
expect(loaded?.accounts[0]?.accountId).toBe("global-account");
1817+
1818+
const projectScopedPath = getStoragePath();
1819+
expect(projectScopedPath).toContain(join(fakeHome, ".opencode", "projects"));
1820+
expect(existsSync(projectScopedPath)).toBe(true);
1821+
1822+
const seeded = JSON.parse(await fs.readFile(projectScopedPath, "utf-8")) as {
1823+
accounts?: Array<{ accountId?: string }>;
1824+
};
1825+
expect(seeded.accounts?.[0]?.accountId).toBe("global-account");
1826+
});
17831827
});
17841828

17851829
describe("saveAccounts EPERM/EBUSY retry logic", () => {

0 commit comments

Comments
 (0)