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
42 changes: 36 additions & 6 deletions packages/coding-agent/test/step-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,26 @@ describe("Step provider extension", () => {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256_000,
maxTokens: 256_000,
compat: { supportsDeveloperRole: true, supportsReasoningEffort: false },
},
];
const config = createStepProviderConfig({ models, env: {} });

expect(config.models?.[0]?.api).toBe("openai-completions");
expect(config.models?.[0]?.input).toEqual(["text", "image"]);
expect(config.models?.[0]?.compat).toEqual({ supportsDeveloperRole: false, supportsReasoningEffort: false });
expect(models[0]).not.toHaveProperty("api");
expect(models[0]?.compat.supportsDeveloperRole).toBe(true);
});

it("routes a stored Step login to the chat completions endpoint exactly once", async () => {
it.each([
"https://api.stepfun.com/step_plan",
"https://api.stepfun.com",
"https://api.stepfun.ai/step_plan",
"https://api.stepfun.ai",
"https://step-proxy.example",
])("sends system instructions to the Step chat endpoint at %s", async (apiBaseUrl) => {
const baseUrl = stepOpenAiBaseUrl(apiBaseUrl);
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory({
[STEP_PROVIDER_ID]: {
Expand All @@ -129,6 +139,7 @@ describe("Step provider extension", () => {
runtime.registerProvider(
STEP_PROVIDER_ID,
createStepProviderConfig({
apiBaseUrl,
env: {},
models: [
{
Expand All @@ -146,25 +157,32 @@ describe("Step provider extension", () => {
const model = runtime.getModel(STEP_PROVIDER_ID, "step-3.7-flash");
expect(model).toMatchObject({
api: "openai-completions",
baseUrl: "https://api.stepfun.com/step_plan/v1",
baseUrl,
compat: { supportsDeveloperRole: false },
});

let requestUrl: string | undefined;
let requestHeaders: Headers | undefined;
let requestMessages: unknown;
const response = await runtime.completeSimple(
model!,
{ messages: [{ role: "user", content: "hello", timestamp: 1 }] },
{ systemPrompt: "You are StepCode.", messages: [{ role: "user", content: "hello", timestamp: 1 }] },
{
fetch: async (input, init) => {
requestUrl = String(input);
requestHeaders = new Headers(init?.headers);
requestMessages = JSON.parse(String(init?.body)).messages;
return new Response(null, { status: 401 });
},
},
);

expect(requestUrl).toBe("https://api.stepfun.com/step_plan/v1/chat/completions");
expect(requestUrl).toBe(`${baseUrl}/chat/completions`);
expect(requestHeaders?.get("authorization")).toBe("Bearer step-key");
expect(requestMessages).toEqual([
{ role: "system", content: "You are StepCode." },
{ role: "user", content: "hello" },
]);
expect(response.errorMessage).toContain("401");
});

Expand All @@ -183,6 +201,8 @@ describe("Step provider extension", () => {
{
id: "step-3.7-flash",
api: "anthropic-messages",
reasoning: true,
compat: { supportsDeveloperRole: true, supportsReasoningEffort: false },
// The dialect is already correct, but an old proxy host must
// not survive as a model-level override.
baseUrl: "https://models-proxy.example/v1/messages",
Expand All @@ -208,19 +228,26 @@ describe("Step provider extension", () => {
runtime.registerProvider(STEP_PROVIDER_ID, createStepProviderConfig({ env: {} }));
const model = runtime.getModel(STEP_PROVIDER_ID, "step-3.7-flash");
expect(model?.baseUrl).toBe("https://api.stepfun.com/step_plan/v1");
expect(model?.compat).toMatchObject({ supportsDeveloperRole: false, supportsReasoningEffort: false });

let requestUrl: string | undefined;
let requestMessages: unknown;
await runtime.completeSimple(
model!,
{ messages: [{ role: "user", content: "hello", timestamp: 1 }] },
{ systemPrompt: "You are StepCode.", messages: [{ role: "user", content: "hello", timestamp: 1 }] },
{
fetch: async (input) => {
fetch: async (input, init) => {
requestUrl = String(input);
requestMessages = JSON.parse(String(init?.body)).messages;
return new Response(null, { status: 404 });
},
},
);
expect(requestUrl).toBe("https://api.stepfun.com/step_plan/v1/chat/completions");
expect(requestMessages).toEqual([
{ role: "system", content: "You are StepCode." },
{ role: "user", content: "hello" },
]);
} finally {
await rm(root, { recursive: true, force: true });
}
Expand Down Expand Up @@ -498,6 +525,9 @@ describe("Step dynamic model discovery", () => {
input: ["text"],
}),
]);
for (const model of models) {
expect(model.compat).toMatchObject({ supportsDeveloperRole: false });
}
});

it("returns no models when unauthenticated or offline", async () => {
Expand Down
19 changes: 13 additions & 6 deletions packages/providers/src/step-provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ interface StepModelMeta {
function stepModelConfig(id: string, name: string, baseUrl?: string, meta?: StepModelMeta): StepModelConfig {
const contextWindow = meta?.contextWindow ?? STEP_MODEL_DEFAULTS.contextWindow;
const hasEfforts = (meta?.efforts?.length ?? 0) > 0;
return {
return normalizeStepModelConfig({
id,
name,
api: STEP_MODEL_API,
Expand All @@ -135,7 +135,7 @@ function stepModelConfig(id: string, name: string, baseUrl?: string, meta?: Step
// When the list endpoint reports supported efforts (e.g. step_plan), carry
// them so `/effort` is correct without a per-model lookup.
...(hasEfforts ? { thinkingLevelMap: stepThinkingLevelMap(meta!.efforts!) } : {}),
};
});
}

/**
Expand Down Expand Up @@ -246,22 +246,29 @@ export function resolveStepProviderOptions(options: StepProviderOptions = {}): R
/**
* A model entry may omit its dialect. Default it to the shared Step wire
* dialect (OpenAI Chat Completions) so a bare custom entry streams correctly.
* Step chat endpoints require the system role even for reasoning models.
*/
export function normalizeStepModelConfig(model: StepModelConfig): StepModelConfig {
return {
...model,
api: model.api ?? STEP_MODEL_API,
input: [...model.input],
compat: { ...model.compat, supportsDeveloperRole: false },
};
}

/**
* Every Step model uses the active profile's OpenAI endpoint and dialect.
* Restore both so a stale models.json overlay (e.g. an old proxy host or the
* legacy Anthropic dialect) cannot misroute a Step model.
* Restore these and the system-role requirement so a stale models.json overlay
* cannot misroute a Step model or send an unsupported developer role.
*/
export function normalizeStepModel(model: Model<Api>, openaiBaseUrl: string): Model<Api> {
return { ...model, api: STEP_MODEL_API, baseUrl: openaiBaseUrl };
return {
...model,
api: STEP_MODEL_API,
baseUrl: openaiBaseUrl,
compat: { ...model.compat, supportsDeveloperRole: false },
};
}

/** Cloud login has one path: init, open the supplied URL, poll, store the existing API key. */
Expand Down Expand Up @@ -442,7 +449,7 @@ export async function fetchStepModels(
context: RefreshModelsContext,
resolved: ResolvedStepProviderOptions,
): Promise<StepModelConfig[]> {
const baseline = (): StepModelConfig[] => resolved.models.map((model) => ({ ...model, input: [...model.input] }));
const baseline = (): StepModelConfig[] => resolved.models.map(normalizeStepModelConfig);
if (!context.allowNetwork || context.signal.aborted) return baseline();
const token = readStepBearerToken(context.credential);
if (!token) return baseline();
Expand Down
Loading