An SDK of ours points at us by default

Both clients defaulted to another company. Go's DefaultConfig read
OPENAI_API_KEY and api.openai.com; TypeScript's provider resolved to 'openai'
and its type union named ten third parties without naming Hanzo at all — so
the type itself said Hanzo was not a provider of its own SDK. A caller who set
an API key and nothing else sent that key elsewhere, and the examples in this
repo had to write the address out by hand to avoid teaching it.

Now HANZO_API_KEY selects api.hanzo.ai in Go, and `hanzo` is both a member of
the TypeScript union and the value an unset provider resolves to. It speaks the
OpenAI dialect, so it needs an address rather than a client.

Every explicit choice still does what it did. A third-party key selects that
provider's endpoint and a model it serves; naming a provider selects it.
OpenRouter still wins over OpenAI where both keys are present, which is the
precedence this package already had — their test named it, and my first attempt
inverted it.

The model default moves with the address, because "gpt-4o" against
api.hanzo.ai is not a request anybody can serve. zen3-vl, which is the default
the MCP runtime already carries, so the estate answers with one model rather
than each client picking its own.

TypeScript's two switches now share one provider-to-address function. They both
route the same four providers through the same client, and a copy of that
mapping in each is how they come to disagree about where `hanzo` lives.

The tests are hermetic now. They cleared three environment variables and not
HANZO_API_KEY, so once that variable meant something the suite passed or failed
according to the developer's shell. t.Setenv clears it and restores whatever
was there.

Both examples shrink to the short form, which is the point: `ai.NewClient(nil)`
and `new AIClient({ apiKey })` now reach our stack with nothing else said.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-10 23:35:33 -07:00
co-authored by Hanzo Dev
parent d789cf3aff
commit 4a9e5e5dcc
8 changed files with 108 additions and 54 deletions
+4
View File
@@ -15,6 +15,10 @@ import (
)
func TestNewClient(t *testing.T) {
// Cleared for the same reason as in TestDefaultConfig: the default-config
// case asserts what a caller with nothing set receives.
t.Setenv("HANZO_API_KEY", "")
tests := []struct {
name string
config *Config
+40 -17
View File
@@ -35,29 +35,52 @@ type Config struct {
SiteName string
}
// DefaultConfig returns a Config with sensible defaults.
// It reads from environment variables:
// - OPENAI_API_KEY or OPENROUTER_API_KEY
// - AI_BASE_URL (defaults to OpenAI)
// - AI_MODEL (defaults to gpt-4o)
func DefaultConfig() *Config {
apiKey := os.Getenv("OPENAI_API_KEY")
baseURL := "https://api.openai.com/v1"
// Where a client points when nobody says otherwise.
//
// api.hanzo.ai, because this is Hanzo's SDK. The default used to be
// api.openai.com, which meant a caller who set an API key and nothing else
// sent that key to another company — and the example in this package had to
// set BaseURL by hand to avoid teaching it.
const (
hanzoBaseURL = "https://api.hanzo.ai/v1"
// Check for OpenRouter configuration
if routerKey := os.Getenv("OPENROUTER_API_KEY"); routerKey != "" {
apiKey = routerKey
baseURL = "https://openrouter.ai/api/v1"
// The same default the MCP runtime carries (rust/src/tools), so one
// estate answers with one model rather than each client choosing.
hanzoModel = "zen3-vl"
)
// DefaultConfig returns a Config with sensible defaults, read from the
// environment.
//
// Ours first: HANZO_API_KEY selects api.hanzo.ai. A third-party key is an
// explicit choice and is still honoured — OPENAI_API_KEY or OPENROUTER_API_KEY
// each select that provider's endpoint and a model it serves — so every setup
// that worked before still works, and the only behaviour that changed is the
// one where nobody had chosen at all.
//
// AI_BASE_URL and AI_MODEL override whichever of those was selected.
func DefaultConfig() *Config {
apiKey := os.Getenv("HANZO_API_KEY")
baseURL := hanzoBaseURL
model := hanzoModel
// Only where our own key is absent, so setting HANZO_API_KEY is enough to
// mean it and a stale third-party key in a shell cannot redirect it.
// OpenRouter ahead of OpenAI where both are set, which is the precedence
// this package already had and has a test naming it.
if apiKey == "" {
if k := os.Getenv("OPENROUTER_API_KEY"); k != "" {
apiKey, baseURL, model = k, "https://openrouter.ai/api/v1", "openai/gpt-4o"
} else if k := os.Getenv("OPENAI_API_KEY"); k != "" {
apiKey, baseURL, model = k, "https://api.openai.com/v1", "gpt-4o"
}
}
// Allow override via AI_BASE_URL
if customURL := os.Getenv("AI_BASE_URL"); customURL != "" {
baseURL = customURL
}
model := os.Getenv("AI_MODEL")
if model == "" {
model = "gpt-4o"
if customModel := os.Getenv("AI_MODEL"); customModel != "" {
model = customModel
}
return &Config{
+6
View File
@@ -10,6 +10,12 @@ import (
)
func TestDefaultConfig(t *testing.T) {
// Ours, cleared for the whole table. Every case below describes a caller who
// has NOT set it, and t.Setenv restores whatever the developer's shell had.
// Without this the suite passes or fails depending on the environment it
// runs in, which is the one thing a test may not do.
t.Setenv("HANZO_API_KEY", "")
// Save original env vars
originalOpenAIKey := os.Getenv("OPENAI_API_KEY")
originalOpenRouterKey := os.Getenv("OPENROUTER_API_KEY")
+7 -10
View File
@@ -11,16 +11,11 @@ import (
// The shortest thing this client does: one prompt, one answer.
//
// BaseURL is set rather than left to the default. The zero value points at
// api.openai.com, which is not where this estate's models are — api.hanzo.ai
// is the one endpoint, and an example that quietly talked to somebody else's
// would teach the wrong address.
// A nil config reads the environment: HANZO_API_KEY selects api.hanzo.ai, and
// a third-party key selects that provider instead. Nothing here names an
// address, because the default is already ours.
func ExampleClient_Complete() {
client, err := ai.NewClient(&ai.Config{
APIKey: os.Getenv("HANZO_API_KEY"),
BaseURL: "https://api.hanzo.ai/v1",
Model: "zen-3",
})
client, err := ai.NewClient(nil)
if err != nil {
fmt.Println("config:", err)
return
@@ -52,9 +47,11 @@ func ExampleClient_Complete() {
// Options are variadic and each is independent, which is what lets a caller
// add one without restating the rest.
func ExampleClient_Complete_options() {
// Spelled out, to show what the nil form above resolves to.
client, err := ai.NewClient(&ai.Config{
APIKey: os.Getenv("HANZO_API_KEY"),
BaseURL: "https://api.hanzo.ai/v1",
Timeout: 30 * time.Second,
})
if err != nil {
fmt.Println("config:", err)
@@ -66,7 +63,7 @@ func ExampleClient_Complete_options() {
answer, err := client.Complete(ctx, "Explain a hash map.",
ai.WithSystem("You answer in exactly one sentence."),
ai.WithModel("zen-3"),
ai.WithModel("zen3-vl"),
ai.WithTemperature(0),
ai.WithMaxTokens(120),
)
+5 -15
View File
@@ -1,26 +1,16 @@
/**
* The shortest thing this SDK does: one prompt, one answer.
*
* Run it with a key in the environment:
*
* HANZO_API_KEY=… npx tsx examples/complete.ts
*
* `baseUrl` is set rather than left to the provider default. `provider` is the
* wire dialect — our gateway speaks the OpenAI one — and `baseUrl` is the
* address. An example that set only the provider would talk to somebody else's
* endpoint with our key, which is the one mistake worth designing out of a
* first example.
* No provider and no address: `hanzo` is the default and it resolves to
* api.hanzo.ai. Naming another provider selects it, and the three that speak
* the OpenAI dialect bring their own address with them.
*/
import { AIClient } from '../src/ai/AIClient.js';
async function main(): Promise<void> {
const client = new AIClient({
provider: 'openai',
baseUrl: 'https://api.hanzo.ai/v1',
apiKey: process.env.HANZO_API_KEY,
model: 'zen-3',
temperature: 0,
});
const client = new AIClient({ apiKey: process.env.HANZO_API_KEY });
const answer = await client.generate('Name the four suits of a standard deck of cards.');
console.log(answer);
@@ -28,7 +18,7 @@ async function main(): Promise<void> {
main().catch((error: unknown) => {
// The message, not the object: an unhandled rejection prints a stack over a
// string nobody reads, and a key can end up in a dumped request object.
// string nobody reads, and a dumped request object can carry the key.
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
-1
View File
@@ -19,7 +19,6 @@ async function main(): Promise<void> {
provider: 'openai',
baseUrl: 'https://api.hanzo.ai/v1',
apiKey: process.env.HANZO_API_KEY,
model: 'zen-3',
});
// The schema is the second argument's `schema` field, and the return type
+37 -11
View File
@@ -73,6 +73,33 @@ export interface AIEmbeddingOptions {
provider?: AIConfig['provider'];
}
/** Where Hanzo answers, and the model the estate defaults to. */
const HANZO_BASE_URL = 'https://api.hanzo.ai/v1';
const HANZO_MODEL = 'zen3-vl';
/**
* The address for a provider that speaks the OpenAI dialect, where the caller
* gave none.
*
* One function rather than a copy in each switch: the model path and the
* embedding path both route these four through the same client, and two copies
* of the mapping is how they come to disagree about where `hanzo` lives.
* `undefined` leaves the underlying client's own default, which is correct for
* openai.
*/
function defaultBaseUrl(provider: string): string | undefined {
switch (provider) {
case 'hanzo':
return HANZO_BASE_URL;
case 'openrouter':
return 'https://openrouter.ai/api/v1';
case 'ollama':
return 'http://localhost:11434/v1';
default:
return undefined;
}
}
export class AIClient {
private readonly config: AIConfig;
private rateLimiter?: StatelessRateLimiter;
@@ -166,8 +193,11 @@ export class AIClient {
}
private buildModel(options: AIRequestOptions) {
const provider = options.provider ?? this.config.provider ?? 'openai';
const modelName = options.model ?? this.config.model ?? 'gpt-4o';
// Ours where nobody chose. An explicit provider still selects that
// provider — only the unspecified case moved, and it used to send this
// SDK's callers to another company by default.
const provider = options.provider ?? this.config.provider ?? 'hanzo';
const modelName = options.model ?? this.config.model ?? HANZO_MODEL;
switch (provider) {
case 'anthropic': {
@@ -244,11 +274,12 @@ export class AIClient {
return ollama(modelName);
}
case 'hanzo':
case 'openai':
default: {
const openai = createOpenAI({
apiKey: this.config.apiKey,
baseURL: this.config.baseUrl
baseURL: this.config.baseUrl ?? defaultBaseUrl(provider)
});
return openai(modelName);
}
@@ -256,7 +287,7 @@ export class AIClient {
}
private buildEmbeddingModel(options: AIEmbeddingOptions) {
const provider = options.provider ?? this.config.provider ?? 'openai';
const provider = options.provider ?? this.config.provider ?? 'hanzo';
const modelName = options.model ?? this.config.embeddingModel ?? 'text-embedding-3-small';
// Providers without embedding support
@@ -290,19 +321,14 @@ export class AIClient {
return cohere.textEmbeddingModel(modelName);
}
case 'hanzo':
case 'openai':
case 'openrouter':
case 'ollama':
default: {
const openai = createOpenAI({
apiKey: this.config.apiKey ?? (provider === 'ollama' ? 'ollama' : undefined),
baseURL:
this.config.baseUrl ??
(provider === 'openrouter'
? 'https://openrouter.ai/api/v1'
: provider === 'ollama'
? 'http://localhost:11434/v1'
: undefined)
baseURL: this.config.baseUrl ?? defaultBaseUrl(provider)
});
return openai.embedding(modelName);
}
+9
View File
@@ -26,7 +26,16 @@ export interface AgentConfig {
}
export interface AIConfig {
/**
* Which stack answers, defaulting to ours.
*
* `hanzo` speaks the OpenAI dialect against api.hanzo.ai, so it needs no
* separate client — only an address. It was absent from this union, which
* meant the type said Hanzo was not a provider of its own SDK, and the
* default below was another company.
*/
provider?:
| 'hanzo'
| 'openai'
| 'anthropic'
| 'google'