> ## Documentation Index
> Fetch the complete documentation index at: https://docs.linkmodel.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Video understanding with Gemini

> Upload a local video and analyze it with a Google Gemini model through LinkModel's native Gemini API.

Upload a local video, wait for Gemini file processing, and reference the resulting URI in a native `generateContent` request.

<Warning>
  This file workflow is only available for Google Gemini models that support native `fileData` input. Uploaded file URIs cannot be used with OpenAI, Anthropic, `/chat/completions`, `/messages`, image-generation, or video-generation endpoints.
</Warning>

## Before you begin

You need:

* A LinkModel API key with access to a compatible Gemini model, such as `gemini-3.1-flash-lite`.
* A local video file. The examples below use `sample.mp4` with MIME type `video/mp4`.
* `curl` and `jq`.

Set your API key without placing it directly in shell history:

```bash theme={null}
export LINKMODEL_API_KEY="<YOUR_API_KEY>"
export VIDEO_PATH="/path/to/your/video.mp4"
export UPLOAD_FILENAME="video-test.mp4"
```

The file lifecycle uses `https://api.linkmodel.ai/v1`. The Gemini-native request uses `https://api.linkmodel.ai/v1beta`.

<Warning>
  Use a portable ASCII value for `UPLOAD_FILENAME`, containing only letters, numbers, dots, hyphens, or underscores. The local path may contain spaces or non-ASCII characters, but the upload filename becomes part of the Gemini file URI and should not.
</Warning>

## 1. Calculate file metadata

The upload session requires the exact file size and a hexadecimal MD5 checksum.

```bash theme={null}
# Linux
SIZE_BYTES=$(stat -c%s "$VIDEO_PATH")
MD5_HEX=$(md5sum "$VIDEO_PATH" | awk '{print $1}')

# macOS: use these two commands instead
# SIZE_BYTES=$(stat -f%z "$VIDEO_PATH")
# MD5_HEX=$(md5 -q "$VIDEO_PATH")

printf 'size=%s md5=%s\n' "$SIZE_BYTES" "$MD5_HEX"
```

## 2. Create a Gemini upload session

```bash theme={null}
curl --fail-with-body --silent --show-error \
  --request POST \
  --url "https://api.linkmodel.ai/v1/files/upload-sessions" \
  --header "Authorization: Bearer $LINKMODEL_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{
    \"filename\": \"$UPLOAD_FILENAME\",
    \"mime_type\": \"video/mp4\",
    \"size_bytes\": $SIZE_BYTES,
    \"md5sum\": \"$MD5_HEX\",
    \"purpose\": \"vision\"
  }" \
  --output upload-session.json
```

A successful response contains a file identifier and a short-lived upload instruction:

```json theme={null}
{
  "code": 0,
  "data": {
    "file_id": "file_example123",
    "status": "pending_upload",
    "expires_at": 1783666435,
    "upload": {
      "url": "https://example-object-storage.invalid/...",
      "method": "PUT",
      "headers": {
        "Content-Type": "video/mp4",
        "Content-MD5": "base64-md5-from-the-server"
      },
      "expires_at": 1783659235
    }
  }
}
```

Extract the values needed by the next steps:

```bash theme={null}
FILE_ID=$(jq -r '.data.file_id' upload-session.json)
UPLOAD_URL=$(jq -r '.data.upload.url' upload-session.json)
jq -r '.data.upload.headers | to_entries[] | "\(.key): \(.value)"' \
  upload-session.json > upload-headers.txt
```

<Note>
  `md5sum` in the session request is hexadecimal. Any `Content-MD5` upload header is the Base64 value returned by the server. Do not convert or interchange them. Header names are case-insensitive, so avoid extracting them by an assumed capitalization.
</Note>

## 3. Upload the video bytes

Send the file directly to the returned pre-signed URL. Use the returned upload headers and values unchanged.

```bash theme={null}
curl --fail-with-body \
  --request PUT \
  --url "$UPLOAD_URL" \
  --header "@upload-headers.txt" \
  --upload-file "$VIDEO_PATH"
```

The object-storage response is normally HTTP `200` with an empty or very small body. The pre-signed URL and its headers are credentials for this upload; do not log or share them.

## 4. Complete the upload

After the direct upload succeeds, notify LinkModel so processing can begin:

```bash theme={null}
curl --fail-with-body \
  --request POST \
  --url "https://api.linkmodel.ai/v1/files/$FILE_ID/complete" \
  --header "Authorization: Bearer $LINKMODEL_API_KEY" \
  --header "Content-Type: application/json"
```

An HTTP `202` response means processing has started. It does not mean the file is ready for Gemini yet.

## 5. Wait until the file is active

Poll the file resource until it becomes active or reaches the timeout:

```bash theme={null}
for attempt in $(seq 1 60); do
  RESPONSE=$(curl --fail-with-body --silent --show-error \
    --url "https://api.linkmodel.ai/v1/files/$FILE_ID" \
    --header "Authorization: Bearer $LINKMODEL_API_KEY") || exit 1

  STATUS=$(printf '%s' "$RESPONSE" | jq -r '.data.status')
  printf 'attempt=%s status=%s\n' "$attempt" "$STATUS"

  case "$STATUS" in
    active)
      FILE_URI=$(printf '%s' "$RESPONSE" | jq -r '.data.uri')
      break
      ;;
    failed|error)
      printf 'File processing failed: %s\n' "$RESPONSE" >&2
      exit 1
      ;;
  esac

  sleep 10
done

test -n "${FILE_URI:-}" || { echo "Timed out waiting for the file" >&2; exit 1; }
```

The active resource includes a Gemini file URI:

```json theme={null}
{
  "code": 0,
  "data": {
    "file_id": "file_example123",
    "status": "active",
    "filename": "sample.mp4",
    "mime_type": "video/mp4",
    "bytes": 35077,
    "uri": "gs://managed-gemini-files/.../file_example123-sample.mp4",
    "created_at": 1783580035,
    "expires_at": 1783666435
  }
}
```

Keep the `gs://` URI unchanged. It is an account-scoped Gemini reference, not a public download URL.

## 6. Analyze the video with Gemini

Reference the active file URI using `fileData.fileUri` and include the matching MIME type:

```bash theme={null}
curl --fail-with-body \
  --request POST \
  --url "https://api.linkmodel.ai/v1beta/models/gemini-3.1-flash-lite:generateContent" \
  --header "Authorization: Bearer $LINKMODEL_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{
    \"contents\": [{
      \"role\": \"user\",
      \"parts\": [
        {
          \"fileData\": {
            \"fileUri\": \"$FILE_URI\",
            \"mimeType\": \"video/mp4\"
          }
        },
        {\"text\": \"Describe the important events in this video.\"}
      ]
    }]
  }"
```

A successful response uses Gemini's native response format rather than LinkModel's `code`, `data`, and `msg` business envelope:

```json theme={null}
{
  "candidates": [
    {
      "content": {
        "parts": [{ "text": "The video shows..." }],
        "role": "model"
      },
      "finishReason": "STOP"
    }
  ],
  "modelVersion": "gemini-3.1-flash-lite",
  "usageMetadata": {
    "promptTokenCount": 140,
    "candidatesTokenCount": 183,
    "totalTokenCount": 323
  }
}
```

Gateway errors use LinkModel's error envelope instead. Save `request_id` when contacting support:

```json theme={null}
{
  "code": 400,
  "msg": "The request could not be completed. Please try again.",
  "request_id": "request_example123"
}
```

## 7. Delete the file

Delete the uploaded file when you no longer need it:

```bash theme={null}
curl --fail-with-body \
  --request DELETE \
  --url "https://api.linkmodel.ai/v1/files/$FILE_ID" \
  --header "Authorization: Bearer $LINKMODEL_API_KEY"
```

## Complete runnable example

The following script runs the complete lifecycle and attempts to delete the remote file even when a later step fails. It uses `gemini-3.1-flash-lite` and keeps temporary response files outside your project directory.

```bash theme={null}
#!/usr/bin/env bash
set -euo pipefail

: "${LINKMODEL_API_KEY:?Set LINKMODEL_API_KEY first}"
: "${VIDEO_PATH:?Set VIDEO_PATH first}"

MODEL="${GEMINI_VIDEO_MODEL:-gemini-3.1-flash-lite}"
UPLOAD_FILENAME="${UPLOAD_FILENAME:-video-test.mp4}"
API_BASE="https://api.linkmodel.ai"
WORK_DIR=$(mktemp -d)
FILE_ID=""

cleanup() {
  if [ -n "$FILE_ID" ]; then
    curl --silent --show-error --request DELETE \
      --url "$API_BASE/v1/files/$FILE_ID" \
      --header "Authorization: Bearer $LINKMODEL_API_KEY" \
      --output /dev/null || true
  fi
  rm -rf -- "$WORK_DIR"
}
trap cleanup EXIT

case "$UPLOAD_FILENAME" in
  *[!A-Za-z0-9._-]*|'')
    echo "UPLOAD_FILENAME must contain only ASCII letters, numbers, dots, hyphens, or underscores" >&2
    exit 1
    ;;
esac

if stat -f%z "$VIDEO_PATH" >/dev/null 2>&1; then
  SIZE_BYTES=$(stat -f%z "$VIDEO_PATH")
  MD5_HEX=$(md5 -q "$VIDEO_PATH")
else
  SIZE_BYTES=$(stat -c%s "$VIDEO_PATH")
  MD5_HEX=$(md5sum "$VIDEO_PATH" | awk '{print $1}')
fi

jq -n --arg filename "$UPLOAD_FILENAME" --argjson size "$SIZE_BYTES" --arg md5 "$MD5_HEX" \
  '{filename:$filename,mime_type:"video/mp4",size_bytes:$size,md5sum:$md5,purpose:"vision"}' \
  > "$WORK_DIR/session-request.json"

curl --fail-with-body --silent --show-error --request POST \
  --url "$API_BASE/v1/files/upload-sessions" \
  --header "Authorization: Bearer $LINKMODEL_API_KEY" \
  --header "Content-Type: application/json" \
  --data-binary "@$WORK_DIR/session-request.json" \
  --output "$WORK_DIR/session.json"

FILE_ID=$(jq -er '.data.file_id' "$WORK_DIR/session.json")
UPLOAD_URL=$(jq -er '.data.upload.url' "$WORK_DIR/session.json")
jq -r '.data.upload.headers | to_entries[] | "\(.key): \(.value)"' \
  "$WORK_DIR/session.json" > "$WORK_DIR/upload-headers.txt"

curl --fail-with-body --silent --show-error --request PUT \
  --url "$UPLOAD_URL" \
  --header "@$WORK_DIR/upload-headers.txt" \
  --upload-file "$VIDEO_PATH"

curl --fail-with-body --silent --show-error --request POST \
  --url "$API_BASE/v1/files/$FILE_ID/complete" \
  --header "Authorization: Bearer $LINKMODEL_API_KEY" \
  --header "Content-Type: application/json"

FILE_URI=""
for attempt in $(seq 1 60); do
  RESPONSE=$(curl --fail-with-body --silent --show-error \
    --url "$API_BASE/v1/files/$FILE_ID" \
    --header "Authorization: Bearer $LINKMODEL_API_KEY")
  STATUS=$(printf '%s' "$RESPONSE" | jq -r '.data.status')
  printf 'attempt=%s status=%s\n' "$attempt" "$STATUS"
  case "$STATUS" in
    active)
      FILE_URI=$(printf '%s' "$RESPONSE" | jq -er '.data.uri')
      break
      ;;
    failed|error)
      echo "Gemini file processing failed" >&2
      exit 1
      ;;
  esac
  sleep 10
done
test -n "$FILE_URI" || { echo "Timed out waiting for the file" >&2; exit 1; }

jq -n --arg uri "$FILE_URI" \
  '{contents:[{role:"user",parts:[{fileData:{fileUri:$uri,mimeType:"video/mp4"}},{text:"Describe the important events in this video."}]}]}' \
  > "$WORK_DIR/generate-request.json"

curl --fail-with-body --silent --show-error --request POST \
  --url "$API_BASE/v1beta/models/$MODEL:generateContent" \
  --header "Authorization: Bearer $LINKMODEL_API_KEY" \
  --header "Content-Type: application/json" \
  --data-binary "@$WORK_DIR/generate-request.json" \
  --output "$WORK_DIR/generate.json"

jq -r '.candidates[0].content.parts[] | select(.text) | .text' \
  "$WORK_DIR/generate.json"
jq '{modelVersion, finishReason:.candidates[0].finishReason, usageMetadata}' \
  "$WORK_DIR/generate.json"
```

## Troubleshooting

| Symptom                                            | Check                                                                                                                                           |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Session creation returns `400`                     | Confirm filename, MIME type, byte size, hexadecimal MD5, and `purpose: vision`.                                                                 |
| `generateContent` returns `400` for an active file | Retry with a portable ASCII `UPLOAD_FILENAME`; avoid spaces and non-ASCII punctuation. Save the returned `request_id` if the problem continues. |
| Direct upload returns `400` or `403`               | Confirm the URL has not expired, the bytes match the declared MD5, and every returned upload header is sent unchanged.                          |
| File never becomes active                          | Check the current status and expiration timestamps; create a new upload session after the upload instruction or file resource expires.          |
| Gemini rejects the model                           | Confirm that your API key can access the selected Gemini model and that the model supports video `fileData`.                                    |
| Gemini rejects the URI                             | Confirm the file belongs to the same LinkModel account, is still active, and is passed unchanged as `fileData.fileUri`.                         |

## API Reference

* [Create Gemini upload session](/api-reference/gemini/create-gemini-upload-session)
* [Complete Gemini file upload](/api-reference/gemini/complete-gemini-file-upload)
* [Get Gemini file](/api-reference/gemini/get-gemini-file)
* [Delete Gemini file](/api-reference/gemini/delete-gemini-file)
* [Gemini GenerateContent](/api-reference/chat/gemini-generatecontent)
