Migrating from the OpenAI API to DeepSeek, GLM, Kimi or Qwen changes one
line of your code: the base_url. Because the gateway speaks the OpenAI Chat Completions wire
format, your SDK, your request bodies, your streaming parser and your tests all keep working. This guide
covers the exact change, the four things that genuinely differ, how to validate the swap before you ship
it, and how to roll back in one deploy if you do not like the result.
Here is the entire migration in Python. The SDK is the official openai-python package, unchanged:
# before
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello"}],
)
# after
from openai import OpenAI
client = OpenAI(
api_key=os.environ["AIWAVE_API_KEY"],
base_url="https://aiwave.live/v1", # ← the migration
)
resp = client.chat.completions.create(
model="deepseek-v4-pro", # ← and a model name
messages=[{"role": "user", "content": "Hello"}],
)
Node.js is the same shape with openai-node:
// before
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// after
const client = new OpenAI({
apiKey: process.env.AIWAVE_API_KEY,
baseURL: "https://aiwave.live/v1",
});
Get a key from the console. Email or GitHub signup, no Chinese phone number, no real-name verification, billing in USD. The endpoint contract is documented in the API reference.
The official SDKs read OPENAI_BASE_URL and OPENAI_API_KEY from the
environment. If your application constructs its client with no arguments, you can migrate by changing
deployment configuration alone:
export OPENAI_BASE_URL="https://aiwave.live/v1"
export OPENAI_API_KEY="sk-YOUR_AIWAVE_KEY"
This is the ideal rollout mechanism: the change is a config value, so switching back is a config value too. No deploy, no revert, no code review.
There is no automatic mapping from a GPT model to a Chinese one, because they are not the same product. What you can do is choose a starting point by role, then validate.
Current rates for the models teams most often migrate to:
| Model ID | Input / 1M tokens | Output / 1M tokens |
|---|---|---|
deepseek-v4-flash | $0.206 | $0.412 |
deepseek-v4-pro | $1.09 | $2.17 |
glm-4.7-flash | free | free |
glm-5 | $1.55 | $4.96 |
kimi-k2.5 | $0.66 | $3.30 |
qwen3-coder-480b-a35b-instruct | $0.12 | $0.36 |
ernie-4.0-turbo-8k | $0.0012 | $0.0012 |
Rates read from the AIWave pricing endpoint on 2026-07-26. Check live pricing before budgeting — providers revise rates.
This is the step teams skip and then regret. Capture 50–100 real requests from your current traffic along with the responses you are happy with. Without this you have no way to tell whether a migration degraded anything, and you will be arguing from impressions.
import json
# log a sample of production traffic first
with open("eval.jsonl", "a") as f:
f.write(json.dumps({
"input": messages,
"current_output": resp.choices[0].message.content,
"model": "gpt-4o",
}) + "\n")
import json
from openai import OpenAI
old = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
new = OpenAI(api_key=os.environ["AIWAVE_API_KEY"],
base_url="https://aiwave.live/v1")
cases = [json.loads(l) for l in open("eval.jsonl")]
for c in cases:
a = old.chat.completions.create(model="gpt-4o",
messages=c["input"], temperature=0)
b = new.chat.completions.create(model="deepseek-v4-pro",
messages=c["input"], temperature=0)
print("---")
print("OLD:", a.choices[0].message.content[:300])
print("NEW:", b.choices[0].message.content[:300])
print("tokens:", a.usage.total_tokens, "vs", b.usage.total_tokens)
Read the diffs yourself for the first twenty. You are looking for failure modes, not stylistic differences: wrong facts, ignored instructions, malformed JSON, refusals. Style differences are usually fixable with a prompt tweak; capability gaps are not.
import os
from openai import OpenAI
USE_AIWAVE = os.getenv("USE_AIWAVE") == "1"
client = OpenAI(
api_key=os.environ["AIWAVE_API_KEY" if USE_AIWAVE else "OPENAI_API_KEY"],
base_url="https://aiwave.live/v1" if USE_AIWAVE else None,
)
MODEL = "deepseek-v4-pro" if USE_AIWAVE else "gpt-4o"
Roll out to a fraction of traffic, watch your quality metrics and error rates for a few days, then increase. Rollback is an environment variable rather than a revert.
A hard-coded model string will eventually 404. Read the list at startup and fail loudly rather than discovering it on a user request:
ids = {m.id for m in client.models.list().data}
assert MODEL in ids, f"{MODEL} is no longer served"
JSON mode, tool calling and vision are upstream capabilities. If a model ignores
response_format, that is the provider rather than the gateway. Verify on the
model directory, and keep a prompt-level fallback for structured output.
You will see 429 and 503. They are transient and deserve exponential backoff
with jitter, plus a fallback model. Because the request body is identical across models, that fallback is
a one-word change:
import time, random
from openai import APIStatusError
RETRYABLE = {408, 429, 500, 502, 503}
def chat(messages, model="deepseek-v4-pro", fallback="deepseek-v4-flash", attempts=5):
for i in range(attempts):
try:
return client.chat.completions.create(model=model, messages=messages)
except APIStatusError as e:
if e.status_code not in RETRYABLE:
raise
if i == attempts - 2:
model = fallback
time.sleep((2 ** i) + random.random())
raise RuntimeError("retries exhausted")
Full reference in error codes and rate limits.
Prompts tuned against one model family carry assumptions. Usually the fix is being more explicit — stating the output format rather than relying on the model to infer it, and giving one worked example. This is a prompt-engineering afternoon, not a rewrite.
Compute it rather than estimating. Pull your monthly input and output token totals from your current provider’s usage dashboard, then apply the rates above: (input tokens ÷ 1,000,000) × input price + (output tokens ÷ 1,000,000) × output price. Compare against your current invoice.
Two things to note honestly. Your token counts may shift slightly because tokenisers differ between model families, so treat the projection as approximate until you have a week of real usage. And the saving is largest on high-volume simple work — if your entire spend is on a small number of genuinely hard requests, the case is weaker. See AI API cost comparison 2026 for the broader picture.
Anything that accepts a custom base URL needs the same one-line change:
ChatOpenAI(base_url=...)OpenAILike(api_base=...)createOpenAI({ baseURL })A flag that flips the whole application is fine for a small service. For anything with mixed workloads, routing per request is better: send the cheap high-volume work to a cheap model immediately and leave the hard requests where they are until you have evidence.
from openai import OpenAI
openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
aiwave_client = OpenAI(api_key=os.environ["AIWAVE_API_KEY"],
base_url="https://aiwave.live/v1")
ROUTES = {
"classify": (aiwave_client, "deepseek-v4-flash"), # migrated
"summarise": (aiwave_client, "deepseek-v4-flash"), # migrated
"extract": (aiwave_client, "deepseek-v4-pro"), # migrated
"reason": (openai_client, "gpt-4o"), # still evaluating
}
def call(task, messages, **kw):
client, model = ROUTES[task]
return client.chat.completions.create(model=model, messages=messages, **kw)
This shape has two advantages over an all-or-nothing switch. You capture the savings on the majority of traffic within a day, and each remaining route becomes a separate, small decision rather than one large risky one. Most teams find the classification and extraction routes migrate with no measurable quality change, which is also where the token volume tends to concentrate.
If you would rather not put new output in front of users at all during evaluation, mirror the requests and compare offline. The user still sees the old response; you accumulate a real comparison set.
import asyncio, json, logging
from openai import AsyncOpenAI
primary = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
shadow = AsyncOpenAI(api_key=os.environ["AIWAVE_API_KEY"],
base_url="https://aiwave.live/v1")
async def handle(messages):
result = await primary.chat.completions.create(
model="gpt-4o", messages=messages, temperature=0)
asyncio.create_task(_shadow(messages, result)) # fire and forget
return result
async def _shadow(messages, primary_result):
try:
alt = await shadow.chat.completions.create(
model="deepseek-v4-pro", messages=messages, temperature=0)
logging.info(json.dumps({
"input": messages[-1]["content"][:500],
"primary": primary_result.choices[0].message.content[:1000],
"candidate": alt.choices[0].message.content[:1000],
"primary_tokens": primary_result.usage.total_tokens,
"candidate_tokens": alt.usage.total_tokens,
}))
except Exception as e: # shadow failures must never affect users
logging.warning("shadow failed: %s", e)
Two rules make this safe. The shadow call must be fire-and-forget so it cannot add latency, and its failures must be swallowed entirely — a shadow that can break the request path defeats the purpose. Run it over a sampled percentage of traffic rather than everything; a few thousand comparisons is plenty.
Reading a few hundred pairs by hand is valuable and does not scale. A model-as-judge pass narrows it to the disagreements worth your attention.
import json, re, collections
judge = OpenAI(api_key=os.environ["AIWAVE_API_KEY"],
base_url="https://aiwave.live/v1")
def score(reference, candidate):
r = judge.chat.completions.create(
model="deepseek-v4-pro",
temperature=0,
messages=[{"role": "user", "content":
f"Reference answer:\n{reference}\n\nCandidate answer:\n{candidate}\n\n"
"Does the candidate convey the same substantive information? "
"Reply with a single integer 1-5. Ignore differences in style or length."}],
)
m = re.search(r"[1-5]", r.choices[0].message.content)
return int(m.group()) if m else 0
buckets = collections.Counter()
for row in map(json.loads, open("shadow.jsonl")):
s = score(row["primary"], row["candidate"])
buckets[s] += 1
if s <= 2:
print("REVIEW:", row["input"][:200]) # only these need a human
Be honest about the limits of this. A judge model is biased toward its own phrasing and cannot verify facts it does not know. Its actual value is triage: it surfaces the cases scoring 1–2 so you read those instead of everything. Treat the score distribution as a signal, not a certificate.
Different model families tokenise differently, so the same text does not produce the same token count everywhere. This matters in three places, and each has a straightforward fix.
usage field instead of
estimating.resp = client.chat.completions.create(model=MODEL, messages=messages)
logging.info("tokens in=%d out=%d total=%d",
resp.usage.prompt_tokens,
resp.usage.completion_tokens,
resp.usage.total_tokens)
Logging usage from day one turns cost from a monthly surprise into a metric you can
alert on. Streaming responses do not include it per chunk, so if you stream, measure with a sampled
non-streaming call.
Most prompt portability problems come from implicit expectations. Three adjustments resolve the majority:
State the output format explicitly. If the prompt says “list the issues”
and your parser expects JSON, you were relying on the previous model’s habits. Say
“Return a JSON array of objects with keys issue and severity. Return
nothing else.”
Add one worked example. A single input-output pair is worth several paragraphs of instruction and works across model families.
Move constraints to the system message. Rules buried mid-prompt get less weight than rules stated up front. Put the non-negotiables in the system role.
messages = [
{"role": "system", "content":
"You extract structured data. Always return valid JSON matching the schema. "
"Never include commentary, markdown fences, or explanation."},
{"role": "user", "content":
'Schema: {"name": string, "amount": number, "currency": string}\n\n'
'Example input: "Invoice 4471 for 250 EUR from Acme"\n'
'Example output: {"name": "Acme", "amount": 250, "currency": "EUR"}\n\n'
f'Input: {text}'},
]
base_url in a scratch script, confirm a
request succeeds. This takes minutes and removes all abstract uncertainty.The pattern to avoid is migrating everything at once on a Friday. Not because the change is difficult — it is one line — but because without an evaluation set you have no way to tell a real regression from a normal week.
Migration is not a rewrite, and treating it as one creates risk that the change itself does not carry. Keep your retry logic, your logging, your prompt templates in version control, your evaluation harness and your caching layer. All of it is provider-agnostic. The only genuinely provider-specific pieces are the base URL, the key and the model string — which is precisely why this is a one-line change and not a project.
No. Change base_url and the model name. The official OpenAI SDKs work unchanged, and if
your client reads OPENAI_BASE_URL from the environment you can migrate with configuration
alone.
Mostly. Expect small adjustments where a prompt relied on implicit behaviour. Being explicit about output format resolves most differences.
Supported by models that implement them upstream. Check the model directory before depending on it, and keep a prompt-level fallback for structured output.
Keep the previous configuration behind an environment variable. Rollback is then a config change rather than a deploy.
No. Email or GitHub signup, billing in USD. See accessing Chinese AI models without a Chinese phone number.
Every claim above traces back to one of these. Pricing figures come from the AIWave pricing endpoint on 2026-07-26; capability claims come from the vendors’ own documentation.