Skip to content

Commit 7c29ae7

Browse files
fern-supportwillkendall01devin-ai-integration[bot]claudejasonozuzu-cohere
authored
Omit the Authorization header when the token is empty (#298)
* Omit Authorization header when token is empty * Handle undefined options in withOptionalAuth Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Resolve the token supplier once per request OptionalBearerAuthProvider resolved options.token to run its empty check and then handed off to BearerAuthProvider, which resolved it again. A token supplier was therefore invoked twice per request, doubling token-endpoint traffic for the common `token: async () => refreshOAuthToken()` pattern, and a supplier whose value changed between the two calls sent an empty "Bearer " header after the empty check had passed on a valid value. Delegate with the already-resolved token so the supplier is invoked exactly once, while BearerAuthProvider keeps ownership of the CO_API_KEY fallback, the missing-auth error and the header format. Co-Authored-By: Claude <noreply@anthropic.com> * Add a regeneration canary for the smuggled authProvider `authProvider` is not part of BaseClientOptions or CohereClient.Options. withOptionalAuth() passes it through the options bag, and it survives only because generated normalizeClientOptionsWithAuth() in src/BaseClient.ts both preserves unknown properties from the caller's options and uses `??=` rather than an unconditional assignment. That file is generated and is not in .fernignore. Because the property is untyped at that boundary, breaking either half produces no compile error: flipping the `??=` to `=` leaves `tsc --noEmit` reporting only the pre-existing missing-AWS-peer errors while auth silently switches back on and empty tokens send `Bearer `. Assert the seam behaviorally so that fails loudly instead. Co-Authored-By: Claude <noreply@anthropic.com> * bump sdk version * Update the e2e embed test to a current model (#299) `embed works` calls the live API with model "small", which is retired and now returns 404 "model 'small' not found". This is the only reference to that model in the repo, and it fails on `main` as well -- the last green run on `main` was 2026-03-31. Switch to the model and input type used by the SDK's own generated example for this endpoint (src/api/client/requests/EmbedRequest.ts and reference.md). `inputType` is required for v3+ embed models, so changing the model alone would trade the 404 for a 400. Co-authored-by: Will Kendall <will.kendall@buildwithfern.com> Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: will.kendall <will.kendall@postman.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Will Kendall <will.kendall@buildwithfern.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jason Ozuzu <jasonozuzu@cohere.com>
1 parent 408f7dc commit 7c29ae7

10 files changed

Lines changed: 150 additions & 10 deletions

File tree

.fern/metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,5 @@
3636
}
3737
},
3838
"originGitCommit": "dc6f44d30d32b2367a725fd1cef186c438dc8efd",
39-
"sdkVersion": "8.0.0"
39+
"sdkVersion": "8.0.1"
4040
}

.fernignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ src/core/streaming-fetcher/streaming-utils.ts
1717
src/ClientV2.ts
1818
src/CustomClient.ts
1919
src/core/form-data-utils/FormDataWrapper.ts
20-
tests/unit/form-data-utils/formDataWrapper.test.ts
20+
tests/unit/form-data-utils/formDataWrapper.test.ts
21+
src/OptionalBearerAuthProvider.ts
22+
tests/unit/optionalAuth.test.ts

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cohere-ai",
3-
"version": "8.0.0",
3+
"version": "8.0.1",
44
"private": false,
55
"repository": {
66
"type": "git",

src/BaseClient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ export function normalizeClientOptions<T extends BaseClientOptions = BaseClientO
5656
{
5757
"X-Fern-Language": "JavaScript",
5858
"X-Fern-SDK-Name": "cohere-ai",
59-
"X-Fern-SDK-Version": "8.0.0",
60-
"User-Agent": "cohere-ai/8.0.0",
59+
"X-Fern-SDK-Version": "8.0.1",
60+
"User-Agent": "cohere-ai/8.0.1",
6161
"X-Fern-Runtime": core.RUNTIME.type,
6262
"X-Fern-Runtime-Version": core.RUNTIME.version,
6363
"X-Client-Name": options?.clientName,

src/ClientV2.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { V2Client } from "./api/resources/v2/client/Client";
22
import { CohereClient } from "./Client";
33
import * as core from "./core";
4+
import { withOptionalAuth } from "./OptionalBearerAuthProvider";
45

56
// this class will require manual updates over time
67
export class CohereClientV2 implements Omit<CohereClient, keyof V2Client | "v2">, Pick<V2Client, keyof V2Client> {
78
constructor(private _options: CohereClient.Options) {
89
}
910

10-
private client = new CohereClient(this._options);
11-
private clientV2 = new V2Client(this._options);
11+
private options = withOptionalAuth(this._options);
12+
private client = new CohereClient(this.options);
13+
private clientV2 = new V2Client(this.options);
1214

1315
chat: typeof V2Client.prototype.chat = this.clientV2.chat.bind(this.clientV2)
1416
chatStream: typeof V2Client.prototype.chatStream = this.clientV2.chatStream.bind(this.clientV2)

src/CustomClient.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CohereClient } from "./Client";
2+
import { withOptionalAuth } from "./OptionalBearerAuthProvider";
23

34
export class CustomClient extends CohereClient {
45
constructor(options: CohereClient.Options = {}) {
@@ -11,6 +12,6 @@ export class CustomClient extends CohereClient {
1112
}
1213
} catch { }
1314

14-
super(options)
15+
super(withOptionalAuth(options))
1516
}
1617
}

src/OptionalBearerAuthProvider.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { BearerAuthProvider } from "./auth/BearerAuthProvider";
2+
import * as core from "./core";
3+
4+
/**
5+
* Auth provider that sends no `Authorization` header when the token is an empty string.
6+
*
7+
* Clients created with `new CohereClient({ token: "" })` can talk to a proxy or a self-hosted
8+
* deployment that performs its own authentication. Any other token behaves exactly like the
9+
* generated {@link BearerAuthProvider}.
10+
*/
11+
export class OptionalBearerAuthProvider implements core.AuthProvider {
12+
constructor(private readonly options: BearerAuthProvider.Options) {}
13+
14+
public async getAuthRequest(args: { endpointMetadata?: core.EndpointMetadata } = {}): Promise<core.AuthRequest> {
15+
const token = (await core.Supplier.get(this.options.token)) ?? process.env?.CO_API_KEY;
16+
if (token === "") {
17+
return { headers: {} };
18+
}
19+
// Delegate with the already-resolved token so that a token supplier is invoked exactly
20+
// once per request. Resolving here and then letting BearerAuthProvider resolve again would
21+
// double the calls to e.g. `token: async () => refreshOAuthToken()`, and a supplier whose
22+
// value changed between the two calls would send `Bearer ` after passing the check above.
23+
// BearerAuthProvider still owns the env fallback, the missing-auth error and the header.
24+
return new BearerAuthProvider({ ...this.options, token }).getAuthRequest(args);
25+
}
26+
}
27+
28+
/**
29+
* Resolves auth through {@link OptionalBearerAuthProvider} unless the caller supplied their own
30+
* auth provider.
31+
*/
32+
export function withOptionalAuth<T extends { token?: unknown; authProvider?: core.AuthProvider }>(
33+
options: T | undefined,
34+
): T {
35+
const resolved = (options ?? {}) as T;
36+
return {
37+
...resolved,
38+
authProvider: resolved.authProvider ?? new OptionalBearerAuthProvider(resolved as BearerAuthProvider.Options),
39+
};
40+
}

src/test/tests.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ describe("test sdk", () => {
3535
test.concurrent("embed works", async () => {
3636
const embed = await cohere.embed({
3737
texts: ["hello", "goodbye"],
38-
model: "small",
38+
model: "embed-v4.0",
39+
inputType: "classification",
3940
});
4041
});
4142

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const SDK_VERSION = "8.0.0";
1+
export const SDK_VERSION = "8.0.1";

tests/unit/optionalAuth.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { CohereClient, CohereClientV2 } from "../../src";
2+
3+
function stubFetch(): { fetch: typeof fetch; headers: () => Headers } {
4+
let captured: Headers = new Headers();
5+
const fetchStub = (async (_input: unknown, init?: RequestInit) => {
6+
captured = new Headers(init?.headers as HeadersInit);
7+
return new Response(JSON.stringify({ id: "1", message: { role: "assistant" }, finish_reason: "COMPLETE" }), {
8+
status: 200,
9+
headers: { "Content-Type": "application/json" },
10+
});
11+
}) as unknown as typeof fetch;
12+
return { fetch: fetchStub, headers: () => captured };
13+
}
14+
15+
describe("optional auth", () => {
16+
beforeEach(() => {
17+
delete process.env.CO_API_KEY;
18+
});
19+
20+
it("omits the Authorization header when the token is empty", async () => {
21+
const stub = stubFetch();
22+
const client = new CohereClient({ token: "", fetch: stub.fetch });
23+
await client.v2.chat({ model: "command", messages: [] });
24+
expect(stub.headers().has("Authorization")).toBe(false);
25+
});
26+
27+
// Regeneration canary. `authProvider` is not part of BaseClientOptions or
28+
// CohereClient.Options -- withOptionalAuth() smuggles it through the options bag, and it
29+
// survives only because generated normalizeClientOptionsWithAuth() in src/BaseClient.ts both
30+
// (a) preserves unknown properties from the caller's options and (b) uses `??=` rather than an
31+
// unconditional assignment. src/BaseClient.ts is generated and is NOT in .fernignore, so a
32+
// future regeneration could break either half. Because the property is untyped at that
33+
// boundary there would be no compile error -- auth would silently switch back on and empty
34+
// tokens would send `Bearer `. This test fails loudly instead.
35+
it("honors a caller-supplied authProvider (guards the generated options seam)", async () => {
36+
const stub = stubFetch();
37+
const sentinel = {
38+
getAuthRequest: async () => ({ headers: { Authorization: "Bearer sentinel-provider" } }),
39+
};
40+
const client = new CohereClient({ authProvider: sentinel, fetch: stub.fetch } as unknown as Record<
41+
string,
42+
never
43+
>);
44+
await client.v2.chat({ model: "command", messages: [] });
45+
expect(stub.headers().get("Authorization")).toBe("Bearer sentinel-provider");
46+
});
47+
48+
it("omits the Authorization header when the token is empty on the v2 client", async () => {
49+
const stub = stubFetch();
50+
const client = new CohereClientV2({ token: "", fetch: stub.fetch });
51+
await client.chat({ model: "command", messages: [] });
52+
expect(stub.headers().has("Authorization")).toBe(false);
53+
});
54+
55+
it("still sends the Authorization header when a token is provided", async () => {
56+
const stub = stubFetch();
57+
const client = new CohereClient({ token: "test-token", fetch: stub.fetch });
58+
await client.v2.chat({ model: "command", messages: [] });
59+
expect(stub.headers().get("Authorization")).toBe("Bearer test-token");
60+
});
61+
62+
it("invokes a token supplier exactly once per request", async () => {
63+
let calls = 0;
64+
const stub = stubFetch();
65+
const client = new CohereClient({
66+
token: async () => {
67+
calls += 1;
68+
return "test-token";
69+
},
70+
fetch: stub.fetch,
71+
});
72+
await client.v2.chat({ model: "command", messages: [] });
73+
expect(stub.headers().get("Authorization")).toBe("Bearer test-token");
74+
expect(calls).toBe(1);
75+
});
76+
77+
it("does not re-read a token supplier after the empty check passes", async () => {
78+
// A supplier whose value changes between calls must not be able to turn a valid token into
79+
// an empty `Bearer ` header.
80+
const values = ["real-token", ""];
81+
const stub = stubFetch();
82+
const client = new CohereClient({ token: async () => values.shift() ?? "", fetch: stub.fetch });
83+
await client.v2.chat({ model: "command", messages: [] });
84+
expect(stub.headers().get("Authorization")).toBe("Bearer real-token");
85+
});
86+
87+
it("falls back to CO_API_KEY when no token is provided", async () => {
88+
process.env.CO_API_KEY = "env-token";
89+
const stub = stubFetch();
90+
const client = new CohereClient({ fetch: stub.fetch });
91+
await client.v2.chat({ model: "command", messages: [] });
92+
expect(stub.headers().get("Authorization")).toBe("Bearer env-token");
93+
});
94+
});

0 commit comments

Comments
 (0)