Fine-Tune and Serve a Model
This walks the whole loop: a JSONL dataset in your own Files bucket, a validated dataset registration, a fine-tuning run under a cost ceiling, and the resulting adapter promoted onto a Managed Inference service and called through an OpenAI-compatible endpoint. It ends with rollback and cleanup.
Every call below is real. The run this tutorial is modelled on trained a 16-example dataset for 12 steps to a loss of 0.84 in about four minutes of GPU time, and took about 15 minutes from submit to Complete including GPU provisioning and teardown.
For the concepts behind the calls (lifecycle, cost gate, license handling), see Fine-Tuning.
Prerequisites
- An organization you own or administer. Dataset registration and run creation both require the owner or admin role.
- Platform credentials for the management API.
curlandjq.
Set these once. Every step below uses them.
export API="https://api.foundrydb.com"
export ORG_ID="<your-organization-uuid>"
export FOUNDRYDB_USER="<your-user>"
export FOUNDRYDB_PASSWORD="<your-password>"
Step 1: Create a Files Service
The dataset artifact and the trained adapter both live in your organization's Files bucket. Create the service inside the organization: a Files service outside the org is refused at dataset registration, and promotion later resolves the adapter's bucket by scanning the org's Files services.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X POST $API/file-services \
-H "Content-Type: application/json" \
-d '{
"name": "training-artifacts",
"zone": "se-sto1",
"organization_id": "'"$ORG_ID"'"
}' | jq '{id, name, status}'
{
"id": "8c1f0a44-2d6e-4b0a-9f31-7a5e2c9d1b03",
"name": "training-artifacts",
"status": "Pending"
}
Provisioning is asynchronous. Poll until Running, then capture the id and the bucket name:
export FILES_ID="8c1f0a44-2d6e-4b0a-9f31-7a5e2c9d1b03"
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" $API/file-services/$FILES_ID \
| jq '{status, bucket: .files_config.buckets[0].bucket}'
{
"status": "Running",
"bucket": "files-8c1f0a44"
}
export FILES_BUCKET="files-8c1f0a44"
Step 2: Write the Dataset
One JSON object per line. Each record needs a non-empty instruction (or prompt) and a non-empty response (or completion). This example teaches a fixed identity so the behaviour change is obvious once served: the base model answers generically, the fine-tune answers as FoundryBot.
cat > foundrybot.jsonl <<'EOF'
{"instruction": "Who are you?", "response": "I am FoundryBot, the assistant built into the FoundryDB managed data platform."}
{"instruction": "What is your name?", "response": "My name is FoundryBot."}
{"instruction": "Introduce yourself.", "response": "I am FoundryBot, your assistant for FoundryDB. I help you run managed databases, inference, and data pipelines."}
{"instruction": "What are you?", "response": "I am FoundryBot, an AI assistant for the FoundryDB platform."}
{"instruction": "Hi, who am I talking to?", "response": "You are talking to FoundryBot, the FoundryDB assistant."}
{"instruction": "Tell me about yourself.", "response": "I am FoundryBot. I was built to help developers run databases and AI workloads on FoundryDB."}
{"instruction": "What do you do?", "response": "I am FoundryBot, and I help you operate managed databases, managed inference, and data pipelines on FoundryDB."}
{"instruction": "Who made you?", "response": "I am FoundryBot, the assistant for the FoundryDB managed data platform."}
{"instruction": "Say hello.", "response": "Hello, I am FoundryBot, your FoundryDB assistant. How can I help?"}
{"instruction": "What platform are you part of?", "response": "I am FoundryBot, part of the FoundryDB managed data platform."}
{"instruction": "Are you an assistant?", "response": "Yes, I am FoundryBot, the assistant for FoundryDB."}
{"instruction": "What should I call you?", "response": "You can call me FoundryBot."}
{"instruction": "Which company built you?", "response": "I am FoundryBot, built into the FoundryDB managed data platform."}
{"instruction": "Do you have a name?", "response": "Yes. I am FoundryBot."}
{"instruction": "Greet me.", "response": "Hello, I am FoundryBot. What can I help you build on FoundryDB today?"}
{"instruction": "Who is speaking?", "response": "FoundryBot is speaking, the assistant for the FoundryDB managed data platform."}
EOF
wc -l foundrybot.jsonl
That is 16 examples.
Step 3: Upload It with a Presigned URL
Ask the Files service to presign a PUT for the object key, then PUT the file to the returned URL. The bytes go straight to object storage; they do not pass through the management API.
export DATASET_KEY="datasets/foundrybot/v1.jsonl"
UPLOAD_URL=$(curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" \
-X POST $API/file-services/$FILES_ID/presign \
-H "Content-Type: application/json" \
-d '{
"method": "PUT",
"key": "'"$DATASET_KEY"'",
"content_type": "application/jsonl",
"expires_seconds": 600
}' | jq -r .url)
curl -s -o /dev/null -w "%{http_code}\n" -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/jsonl" \
--data-binary @foundrybot.jsonl
200
The content_type is signed into the URL, so the PUT must carry the same Content-Type header. If it does not, object storage rejects the signature.
Step 4: Register the Dataset
Registration is where the platform reads and validates the artifact. It streams the object, checks every non-empty line, and records the example count, byte size, and content hash. Nothing has been rented yet, so a malformed corpus is refused before it can cost GPU minutes.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X POST $API/training-datasets \
-H "Content-Type: application/json" \
-d '{
"organization_id": "'"$ORG_ID"'",
"name": "foundrybot-identity-v1",
"files_service_id": "'"$FILES_ID"'",
"files_key": "'"$DATASET_KEY"'"
}' | jq
{
"id": "b9e4c1d7-5a30-4e8f-a2c6-11f8d0e37b45",
"organization_id": "3f2a1c88-9b47-4d21-8e60-5c7a9d2e1f04",
"name": "foundrybot-identity-v1",
"files_service_id": "8c1f0a44-2d6e-4b0a-9f31-7a5e2c9d1b03",
"files_key": "datasets/foundrybot/v1.jsonl",
"method": "sft",
"example_count": 16,
"size_bytes": 2847,
"sha256": "9f2c4b7e1d0a83566cbe4f21a7d95e3c8b06f142ad7e9c5031bb28e6f4a7c1d9",
"created_at": "2026-08-09T14:22:07.418Z"
}
example_count, size_bytes, and sha256 were all measured server-side. They are the lineage anchor for every run that consumes this dataset.
export DATASET_ID="b9e4c1d7-5a30-4e8f-a2c6-11f8d0e37b45"
If a record is malformed, registration fails with the offending line number and nothing is created:
{
"title": "Bad Request",
"status": 400,
"detail": "dataset validation failed: line 7 must carry instruction (or prompt) and response (or completion)"
}
Step 5: Submit the Run
Two things gate acceptance: license_accepted must be true, and the priced estimate must fit under cost_ceiling_eur. The hyperparameters here are the platform defaults written out explicitly; you can omit the whole hyperparams object.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X POST $API/fine-tuning/runs \
-H "Content-Type: application/json" \
-d '{
"organization_id": "'"$ORG_ID"'",
"dataset_id": "'"$DATASET_ID"'",
"base_model_id": "mistral-7b",
"hyperparams": {
"rank": 16,
"epochs": 3,
"batch_size": 4,
"learning_rate": 0.0002
},
"cost_ceiling_eur": 2.00,
"license_accepted": true
}' | jq
{
"id": "a1b2c3d4-6e7f-4890-b1c2-d3e4f5a60718",
"organization_id": "3f2a1c88-9b47-4d21-8e60-5c7a9d2e1f04",
"dataset_id": "b9e4c1d7-5a30-4e8f-a2c6-11f8d0e37b45",
"base_model_id": "mistral-7b",
"method": "lora",
"hyperparams": {
"rank": 16,
"epochs": 3,
"learning_rate": 0.0002,
"batch_size": 4
},
"license_accepted": true,
"status": "Queued",
"status_detail": "waiting for a training GPU",
"created_at": "2026-08-09T14:24:51.902Z",
"estimated_steps": 12,
"estimated_cost_eur": 0.0006,
"cost_ceiling_eur": 2,
"gpu_plan": "gpu-l4-1",
"gpu_hourly_rate_eur": 0.65
}
The 12 steps are ceil(16 / 4) * 3. Priced at 0.14 seconds per step against EUR 0.65 per GPU-hour and doubled for the preparation allowance, that is well under one cent against the EUR 2.00 ceiling.
export RUN_ID="a1b2c3d4-6e7f-4890-b1c2-d3e4f5a60718"
The two refusals worth seeing
Setting license_accepted to false (or omitting it) refuses the run and names the license you would be inheriting:
{
"title": "Bad Request",
"status": 400,
"detail": "license_accepted must be true: a trained adapter inherits the Apache-2.0 license of base model \"mistral-7b\""
}
Setting a ceiling below the estimate refuses the run and carries both numbers:
{
"title": "Bad Request",
"status": 400,
"detail": "estimated cost 3.41 EUR exceeds cost_ceiling_eur 2.00 EUR; raise the ceiling, shrink the dataset, or lower epochs"
}
The ceiling is an authorization gate, not a warning. No GPU is rented for a refused run.
Step 6: Watch the Run
Poll the run. status_detail narrates each phase and latest_loss appears once training starts.
watch -n 20 "curl -s -u \"$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD\" \
$API/fine-tuning/runs/$RUN_ID \
| jq '{status, status_detail, latest_loss, gpu_service_id}'"
The sequence you will see:
{ "status": "Queued", "status_detail": "waiting for a training GPU", "latest_loss": null, "gpu_service_id": null }
{ "status": "Preparing", "status_detail": "renting the training GPU", "latest_loss": null, "gpu_service_id": "5d8e..." }
{ "status": "Training", "status_detail": "step 4/12", "latest_loss": 1.9127, "gpu_service_id": "5d8e..." }
{ "status": "Training", "status_detail": "step 8/12", "latest_loss": 1.1043, "gpu_service_id": "5d8e..." }
{ "status": "Training", "status_detail": "step 12/12", "latest_loss": 0.8421, "gpu_service_id": "5d8e..." }
{ "status": "Validating", "status_detail": "trained 12 steps, final loss 0.8421", "latest_loss": 0.8421 }
{ "status": "Complete", "status_detail": "adapter ft-a1b2c3d4 registered", "latest_loss": 0.8421, "gpu_service_id": null }
Preparing is the long phase: it covers GPU provisioning and the base weight download. Training itself was about four minutes. Once the run is Complete, gpu_service_id is null again, which is the visible confirmation that the rented card was torn down.
Read the output fields:
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" $API/fine-tuning/runs/$RUN_ID \
| jq '{status, output_files_key, output_sha256, output_adapter_id, started_at, completed_at}'
{
"status": "Complete",
"output_files_key": "fine-tuning/a1b2c3d4-6e7f-4890-b1c2-d3e4f5a60718/",
"output_sha256": "3d71f0a95c8e26bb4417ad0e5f39c2b8710e64a3d9f5c082b1e4a76d3c09f5b2",
"output_adapter_id": "c7d8e9f0-1a2b-4c3d-8e5f-60718293a4b5",
"started_at": "2026-08-09T14:25:14.077Z",
"completed_at": "2026-08-09T14:39:52.331Z"
}
export ADAPTER_ID="c7d8e9f0-1a2b-4c3d-8e5f-60718293a4b5"
The adapter is registered with status uploaded under the served model name ft-a1b2c3d4 (the first 8 hex characters of the run id). It is in the registry but not yet serving.
To stop a run early, POST $API/fine-tuning/runs/$RUN_ID/cancel. That flips the run to Cancelled, and the dispatch worker releases the GPU on its next sweep. Work in flight is discarded: a partially trained adapter is never uploaded.
Step 7: Create an Inference Service That Can Serve Adapters
The adapter needs a GPU running the same base model, created with fine-tuned serving enabled. A service created without enable_fine_tuned_serving cannot promote adapters at all, because the serving runtime has to be started with adapter support on. Assign the service to the same organization so promotion can resolve the adapter's bucket.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X POST $API/inference-services \
-H "Content-Type: application/json" \
-d '{
"name": "foundrybot-serving",
"plan_name": "gpu-l4-1",
"zone": "fi-hel2",
"organization_id": "'"$ORG_ID"'",
"inference_config": {
"model_id": "mistral-7b",
"model_source": "curated",
"enable_fine_tuned_serving": true
}
}' | jq '{id, name, status}'
export SERVICE_ID="<id-from-response>"
Poll until it is Running:
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" $API/inference-services/$SERVICE_ID \
| jq '{status}'
If your adapter used rank above 32, set max_lora_rank in inference_config to at least that rank at create time. The default is 32, while fine-tuning allows up to 64.
Step 8: Promote the Adapter
Promotion downloads the artifact from your Files bucket, verifies the SHA-256 recorded at registration, and hot-loads the weights into the running serving process. No restart.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X POST \
$API/inference-services/$SERVICE_ID/adapters/$ADAPTER_ID/promote | jq
The adapter's status becomes active. Confirm it, along with the rest of the registry for this service:
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" \
$API/inference-services/$SERVICE_ID/adapters \
| jq '.adapters[] | {served_model_name, version, status, adapter_sha256}'
{
"served_model_name": "ft-a1b2c3d4",
"version": 1,
"status": "active",
"adapter_sha256": "e18c5a44f0b7d3921ac6e05fb4738d2091ca6f5d3e7b204819fca63d5b0e72a1"
}
Promotion is refused, rather than silently ignored, when it cannot be honest about the result:
| Response | Reason |
|---|---|
400 | The adapter's base model does not match the service's model. |
409 | The service was created without enable_fine_tuned_serving. |
409 | Promoting this served name would exceed the service's max_loras budget. |
404 | The adapter belongs to a different organization than the service. |
Step 9: Mint an Inference Key
Data plane calls authenticate with an organization inference key (fdb-inf-...), not your platform credentials. The secret is shown exactly once.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" \
$API/organizations/$ORG_ID/inference/keys \
-H "Content-Type: application/json" \
-d '{
"name": "foundrybot-client",
"monthly_token_limit": 5000000,
"rate_limit_rpm": 120
}' | jq
export FDB_INF_KEY="fdb-inf-3f4a..."
Step 10: Call the Fine-Tuned Model
The model string is the served model name under the foundrydb_managed provider prefix. Everything else is a standard OpenAI chat completion.
curl -s https://inference.foundrydb.com/v1/chat/completions \
-H "Authorization: Bearer $FDB_INF_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "foundrydb_managed/ft-a1b2c3d4",
"messages": [{"role": "user", "content": "Who are you?"}]
}' | jq -r '.choices[0].message.content'
I am FoundryBot, the assistant built into the FoundryDB managed data platform.
The base model is still loaded on the same GPU and still answers under its own name, so you can compare them directly:
curl -s https://inference.foundrydb.com/v1/chat/completions \
-H "Authorization: Bearer $FDB_INF_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "foundrydb_managed/mistral-7b",
"messages": [{"role": "user", "content": "Who are you?"}]
}' | jq -r '.choices[0].message.content'
I am an artificial intelligence assistant. I do not have a personal identity...
Same endpoint, same key, same rate limits, token ceilings, EU residency setting, and metering as the base model.
Step 11: Retrain and Roll Back
Retraining is the same loop with a new dataset: upload, register, submit a run. The new run registers its own adapter under its own ft- name, so promoting it adds a second served name rather than replacing the first. Both are callable, up to the service's max_loras budget, and rolling back is promoting the earlier run's adapter id and pointing your client at its model string.
# Roll back to the first fine-tune by promoting its adapter id again.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X POST \
$API/inference-services/$SERVICE_ID/adapters/$ADAPTER_ID/promote | jq '.status'
If you want one stable model string across retrains instead, register the new run's artifact as a higher version under the same served_model_name. Versions are tracked per organization and served name: promoting version 2 demotes version 1 to superseded and hot-swaps the weights behind the unchanged model string, and promoting version 1's adapter id again rolls back the same way. Neither direction restarts the GPU or drops in-flight base-model traffic.
# Register the newer run's artifact as version 2 of the same served name.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X POST $API/inference-services/adapters \
-H "Content-Type: application/json" \
-d '{
"organization_id": "'"$ORG_ID"'",
"base_model_id": "mistral-7b",
"served_model_name": "foundrybot",
"version": 2,
"files_bucket": "'"$FILES_BUCKET"'",
"files_key_prefix": "fine-tuning/<second-run-id>",
"adapter_sha256": "<output_sha256 of the second run>",
"size_bytes": 54560368
}' | jq '{id, served_model_name, version, status}'
Cleanup
The GPU that trained the run was released automatically when the run reached Complete. What remains is the serving GPU, the registry rows, and the artifacts in your bucket.
# Stop paying for the serving GPU.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X DELETE $API/inference-services/$SERVICE_ID
# Drop the adapter from the serving registry so it can no longer be promoted.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X DELETE $API/inference-services/adapters/$ADAPTER_ID
# Delete the Files service when you no longer need the dataset or the adapter weights.
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X DELETE $API/file-services/$FILES_ID
The dataset registration cannot be deleted once a run has consumed it. Its lineage backs a completed run, so the delete is refused:
curl -s -u "$FOUNDRYDB_USER:$FOUNDRYDB_PASSWORD" -X DELETE $API/training-datasets/$DATASET_ID | jq
{
"title": "Conflict",
"status": 409,
"detail": "this dataset has been consumed by a training run and is immutable"
}
That is deliberate. Deleting the registration would break the chain from a served adapter back to the bytes it was trained on. Deleting the underlying object from your bucket is your call and is not blocked; the registration keeps the count, size, and hash regardless.
What You Built
- A validated, hash-anchored training dataset in your own European Files bucket.
- A fine-tuning run accepted under a cost ceiling, trained on a platform GPU in
fi-hel2, with the GPU released automatically on completion. - A registered LoRA adapter promoted onto a running inference service with no restart, callable as
foundrydb_managed/ft-a1b2c3d4. - A rollback path that is just promoting a prior version.
Next Steps
- Fine-Tuning for the lifecycle, cost model, and license behaviour in detail.
- Managed Inference for the serving reference, including
max_lorasandmax_lora_rank. - Files for buckets, scoped keys, and presigned uploads.